diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..b7747e68 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,74 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": [ + "./common/tsconfig.json", + "./agent/tsconfig.json", + "./controller/tsconfig.json", + "./controller/tsconfig.test.json" + ] + }, + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/stylistic", + "plugin:@typescript-eslint/recommended" + ], + "ignorePatterns": [ + "guide", + "lib", + "dist", + "next-env.d.ts", + "next.config.js", + "setup.js", + "controller/lib/hdr-histogram-wasm" + ], + "rules": { + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-non-null-assertion": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/ban-types": 1, + "@typescript-eslint/no-inferrable-types": 0, + "@typescript-eslint/no-unused-vars": [1, { "argsIgnorePattern": "^_" }], + "no-prototype-builtins": 1, + "require-await": 1, + "class-name": 0, + "curly": 1, + "eqeqeq": ["error", "smart"], + "linebreak-style": 1, + "object-literal-sort-keys": 0, + "only-arrow-functions": 0, + "max-classes-per-file": 1, + "max-line-length": 0, + "member-ordering": 0, + "no-angle-bracket-type-assertion": 0, + "no-bitwise": 1, + "no-console": 1, + "no-multiple-empty-lines": ["error", { "max": 2, "maxEOF": 0, "maxBOF": 0 }], + "no-empty": [1, { "allowEmptyCatch": true }], + "no-empty-interface": 0, + "no-reference": 0, + "no-string-literal": 0, + "no-trailing-spaces": 1, + "no-unused-expressions": 1, + "no-useless-catch": 0, + "prefer-const": 1, + "semi": 1, + "sort-imports": 1, + "space-before-function-paren": 1, + "spaced-comment": ["error", "always", { "block": { "balanced": true } }], + "space-infix-ops":"warn", + "strict": 1, + "comma-dangle": 1, + "triple-equals": 0, + "unified-signatures": 0, + "camelcase": 1, + "no-irregular-whitespace": 1, + "object-shorthand": 1, + "@typescript-eslint/await-thenable": 1, + "quotes": ["warn", "double"] + } +} diff --git a/.github/workflows/pr-js.yml b/.github/workflows/pr-guide.yml similarity index 92% rename from .github/workflows/pr-js.yml rename to .github/workflows/pr-guide.yml index 70b7cc87..28b52d35 100644 --- a/.github/workflows/pr-js.yml +++ b/.github/workflows/pr-guide.yml @@ -1,14 +1,14 @@ on: pull_request: paths: - - '**.js*' - - '**.ts*' - - '**.html' + - 'guide/**.js*' + - 'guide/**.ts*' + - 'guide/**.html' - 'guide/src/**/*.md' - - '**/package.json' - - '**/package-lock.json' + - 'guide/**/package.json' + - 'guide/**/package-lock.json' -name: Pull Request Javascript +name: Pull Request Guide jobs: create-release: name: Build guide diff --git a/.github/workflows/pr-ppaas.yml b/.github/workflows/pr-ppaas.yml new file mode 100644 index 00000000..a695448e --- /dev/null +++ b/.github/workflows/pr-ppaas.yml @@ -0,0 +1,92 @@ +on: + pull_request: + paths: + - './package.json' + - './package-lock.json' + - 'common/**.js*' + - 'common/**.ts*' + - 'common/**/package.json' + - 'agent/**.js*' + - 'agent/**.ts*' + - 'agent/**/package.json' + - 'controller/**.html' + - 'controller/**.js*' + - 'controller/**.ts*' + - 'controller/**/package.json' + +name: Pull Request PPaaS +jobs: + test-release: + name: Build project + strategy: + matrix: + node-version: [18.x, 20.x] + fail-fast: false + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Add Rust toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + target: wasm32-unknown-unknown + toolchain: stable + override: true + - name: Add Node.js toolchain ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Build Webassemblies ${{ matrix.node-version }} + run: | + set -x + # install mdbook and wasm-pack + mkdir ~/bin + PATH=$PATH:~/bin + curl -sSL https://github.com/rustwasm/wasm-pack/releases/download/v0.12.1/wasm-pack-v0.12.1-x86_64-unknown-linux-musl.tar.gz \ + | tar -xz --strip-components=1 -C ~/bin --no-anchored wasm-pack + + # setup some envs to various paths for convenience + PROJECT_ROOT=$PWD + CONTROLLER_DIR=$(realpath $PROJECT_ROOT/controller) + CONFIG_WASM_LIB_DIR=$(realpath $PROJECT_ROOT/lib/config-wasm) + HDR_WASM_LIB_DIR=$(realpath $PROJECT_ROOT/lib/hdr-histogram-wasm) + HDR_WASM_OUTPUT_REACT_DIR=$CONTROLLER_DIR/lib/hdr-histogram-wasm + + # build the hdr-histogram-wasm for the results viewer + cd $HDR_WASM_LIB_DIR + wasm-pack build --release -t bundler -d $HDR_WASM_OUTPUT_REACT_DIR --scope fs + + # build the config-wasm for the yaml parser + cd $CONFIG_WASM_LIB_DIR + wasm-pack build --release -t nodejs --scope fs + + - name: Create .env file for controller that can be overridden by a .env.production file + run: | + set -x + # setup some envs to various paths that re required for build + PROJECT_ROOT=$PWD + CONTROLLER_DIR=$(realpath $PROJECT_ROOT/controller) + # .env.production will override .env, but NOT .env.local or environment variables passed in + ENV_FILE=$CONTROLLER_DIR/.env + touch "$ENV_FILE" + echo PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="unit-test-bucket" >> "$ENV_FILE" + echo PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://unit-test-bucket.s3.amazonaws.com" >> "$ENV_FILE" + echo PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="unittests/" >> "$ENV_FILE" + echo PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" >> "$ENV_FILE" + echo APPLICATION_NAME=pewpewcontroller >> "$ENV_FILE" + echo AGENT_ENV="unittests" >> "$ENV_FILE" + echo AGENT_DESC="c5n.large" >> "$ENV_FILE" + echo PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-out" >> "$ENV_FILE" + echo PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-in" >> "$ENV_FILE" + echo PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-communication" >> "$ENV_FILE" + + - name: Install NPM Dependencies + run: npm ci + - name: Run Lint + run: npm run linterror + - name: Build Controller + run: npm run build:react + - name: Run Tests + run: NODE_ENV=test npm test diff --git a/.github/workflows/pr.yml b/.github/workflows/pr-rust.yml similarity index 99% rename from .github/workflows/pr.yml rename to .github/workflows/pr-rust.yml index c35c016b..3355baf2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr-rust.yml @@ -5,7 +5,7 @@ on: - '**/Cargo.toml' - '**/Cargo.lock' -name: Pull Request +name: Pull Request Rust jobs: test: name: Test Suite diff --git a/.gitignore b/.gitignore index 9862fdea..c4aba493 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,15 @@ /*.txt /*.dump /*.log -/*.json +/stats-*.json +/test-*.json +/integration.json node_modules +.nyc_output/ +coverage/ +testmerge.json +.env.local* +.env.development.local* +.env.test.local* +.env.production.local* + diff --git a/agent/.ebextensions/15pewpewagent-security.eb.config b/agent/.ebextensions/15pewpewagent-security.eb.config new file mode 100644 index 00000000..8ea8a626 --- /dev/null +++ b/agent/.ebextensions/15pewpewagent-security.eb.config @@ -0,0 +1,13 @@ +Resources: + AWSEBSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 8080 + ToPort: 8081 + CidrIp: 10.0.0.0/8 + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 10.0.0.0/8 diff --git a/agent/.ebextensions/20pewpewagent-performance.config b/agent/.ebextensions/20pewpewagent-performance.config new file mode 100644 index 00000000..51f9931d --- /dev/null +++ b/agent/.ebextensions/20pewpewagent-performance.config @@ -0,0 +1,5 @@ +commands: + 10set-file-max: + command: "echo fs.file-max = 999999 >> /etc/sysctl.conf" + 11set-file-limit: + command: "echo '* - nofile 999999' >> /etc/security/limits.conf" diff --git a/agent/.ebextensions/30pewpewagent-autoscale.eb.config b/agent/.ebextensions/30pewpewagent-autoscale.eb.config new file mode 100644 index 00000000..62c5c16c --- /dev/null +++ b/agent/.ebextensions/30pewpewagent-autoscale.eb.config @@ -0,0 +1,5 @@ +Resources: + AWSEBAutoScalingGroup: + Type: AWS::AutoScaling::AutoScalingGroup + Properties: + DesiredCapacity: 1 \ No newline at end of file diff --git a/agent/.ebextensions/40pewpewagent-change-npm-permissions.config b/agent/.ebextensions/40pewpewagent-change-npm-permissions.config new file mode 100644 index 00000000..1685c387 --- /dev/null +++ b/agent/.ebextensions/40pewpewagent-change-npm-permissions.config @@ -0,0 +1,12 @@ +files: + "/opt/elasticbeanstalk/hooks/appdeploy/post/00_set_tmp_permissions.sh": + mode: "000755" + owner: root + group: root + content: | + #!/usr/bin/env bash + echo "set /tmp permissions start" + sudo chown -R nodejs:nodejs /tmp/.npm + sudo chown -R nodejs:nodejs /tmp/.config + sudo chmod -R 755 /tmp/.config + echo "set /tmp permissions done" \ No newline at end of file diff --git a/agent/.env.test b/agent/.env.test new file mode 100755 index 00000000..48e6e00a --- /dev/null +++ b/agent/.env.test @@ -0,0 +1,15 @@ +# NODE_ENV=test ignores .env and .env.local +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="unit-test-bucket" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://unit-test-bucket.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="unittests/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewagent" + +APPLICATION_NAME=pewpewagent +CONTROLLER_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-communication" + +ENV_KEY=".env.test" diff --git a/agent/.github/workflows/pr.yml b/agent/.github/workflows/pr.yml new file mode 100644 index 00000000..7c85248d --- /dev/null +++ b/agent/.github/workflows/pr.yml @@ -0,0 +1,36 @@ +on: + pull_request: + +name: Pull Request Javascript +jobs: + test-release: + name: Build project + strategy: + matrix: + node-version: [18.x, 20.x] + runs-on: ubuntu-latest + # env: + # USE_XVFB: true + + steps: + - uses: actions/checkout@v2 + + - name: Add Node.js toolchain ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Setup Artifactory + env: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo -e "machine github.com\n login $CI_USER_TOKEN" > ~/.netrc + echo "//familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/:_authToken=${NPM_TOKEN}" >> ~/.npmrc + echo "@fs:registry=https://familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/" >> ~/.npmrc + echo git config --global --add url."https://$CI_USER_TOKEN@github.com/".insteadOf "https://github.com/" + + - name: Install NPM Dependencies + run: npm ci + - name: Run Tests + run: NODE_ENV=test npm test diff --git a/agent/.gitignore b/agent/.gitignore new file mode 100644 index 00000000..4963527b --- /dev/null +++ b/agent/.gitignore @@ -0,0 +1,34 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage +setaws.sh +/.nyc_output/ + +# production +/build + +# development +/dist + +# misc +.DS_Store +.env +.env.local* +.env.development.local* +.env.test.local* +.env.production.local* +.idea +.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +agent.log* +app-pp*.json* diff --git a/agent/.platform/hooks/predeploy/200_npm_rebuild.sh b/agent/.platform/hooks/predeploy/200_npm_rebuild.sh new file mode 100755 index 00000000..381f7b6e --- /dev/null +++ b/agent/.platform/hooks/predeploy/200_npm_rebuild.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -ex + +echo "npm rebuild start" + +APP_STAGING_DIR=$( /opt/elasticbeanstalk/bin/get-config platformconfig -k AppStagingDir ) + +# Add NPM-installed executables to the PATH +NPM_LIB=$( npm list -g | head -1 ) +NPM_HOME=$( dirname "${NPM_LIB}" ) +export PATH="${NPM_HOME}/bin:${PATH}" + +# rebuild to fix the node_modules/.bin/ folder +cd "${APP_STAGING_DIR}" +npm rebuild +chmod a+x node_modules/.bin/* + +echo "npm rebuild done" diff --git a/agent/.sample-env b/agent/.sample-env new file mode 100755 index 00000000..ef81b9c6 --- /dev/null +++ b/agent/.sample-env @@ -0,0 +1,17 @@ +# https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables +# Next.js will load these automatically. We use dotenv-flow to load them for mocha +# Copy this file to .env.local and modify these to your services + +# AWS_PROFILE=default +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="my-test-service" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://my-test-service.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="pewpewcontroller-unittests-s3/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewcontroller" + +APPLICATION_NAME=pewpewagent +CONTROLLER_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewcontroller-unittests-sqs-communication" diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 00000000..38a6de4c --- /dev/null +++ b/agent/README.md @@ -0,0 +1,81 @@ +# ppaas-agent +PewPew as a Service Agent Machine that runs PewPew Tests using Node.js + Typescript + +## Purpose +This allows us to run load tests using [pewpew](https://github.com/FamilySearch/pewpew) in AWS without having to manually create an ec2 instance. By putting the test files in s3 and putting a message on an SQS queue, an EC2 instance will be spun up to run the test, then shutdown when complete. + +### Shared code +Shared code for the agent and the controller are found in [ppaas-common](https://github.com/FamilySearch/pewpew/common) + +## Build + +```bash + +$ npm i && npm run build + +``` + +## Test + +```bash +# This will automatically get called when you try to commit +$ npm test + +``` + +## Integration Tests + +```bash +# You must set your aws credentials and have run the Run the local server below +$ npm run integration + +``` + +## Execute PewPew Local Tests + +```bash + +# You must set your aws credentials +$ npm run createtest + +``` + +## Run the local server + +To start the server, run one of the following commands: + + ```bash + + # You must set your aws credentials to start or run tests + $ npm start + + ``` + +## npm run commands + +```bash + +# You must set your aws credentials to start +# start server +$ npm start + +# build the TypeScript code (output dir: build/) +$ npm run build + +# test +$ npm test + +# Run the coverage tests (unittests + createtest) +# You must set your aws credentials and have run the Run the local server below +$ npm run coverage + +# Run the create and launch tests +# You must set your aws credentials +$ npm run createtest + +# style check TypeScript +$ npm run lint + +# delete the build dir +$ npm run clean +``` diff --git a/agent/acceptance/healthcheck.spec.ts b/agent/acceptance/healthcheck.spec.ts new file mode 100644 index 00000000..a624afb1 --- /dev/null +++ b/agent/acceptance/healthcheck.spec.ts @@ -0,0 +1,91 @@ +import { LogLevel, log } from "@fs/ppaas-common"; +import axios, { AxiosResponse as Response } from "axios"; +import { expect } from "chai"; + +const integrationUrl = process.env.BUILD_APP_URL || "http://localhost:8080"; +log("integrationUrl = " + integrationUrl); + +describe("Healthcheck Integration", () => { + let url: string; + + before(() => { + url = integrationUrl; + log("smoke tests url=" + url, LogLevel.DEBUG); + }); + + describe(integrationUrl + "/healthcheck/", () => { + it("GET healthcheck/ should respond 200 OK", (done: Mocha.Done) => { + axios.get(integrationUrl + "/healthcheck/").then((res: Response) => { + expect(res.status).to.equal(200); + const data = res.data; + expect(data, "data").to.not.equal(undefined); + expect(data?.s3, "data.s3").to.equal(true); + expect(data?.sqs, "data.sqs").to.equal(true); + expect(data?.failHealthCheck, "data.failHealthCheck").to.equal(false); + expect(typeof data?.lastS3Access, "typeof data.lastS3Access").to.equal("string"); + expect(typeof data?.lastSQSAccess, "typeof data.lastSQSAccess").to.equal("string"); + expect(typeof data?.ipAddress, "typeof data.ipAddress").to.equal("string"); + expect(typeof data?.hostname, "typeof data.hostname").to.equal("string"); + expect(typeof data?.instanceId, "typeof data.instanceId").to.equal("string"); + done(); + }).catch((error) => done(error)); + }); + }); + + describe(integrationUrl + "/healthcheck/heartbeat", () => { + it("GET healthcheck/heartbeat should respond 200 OK", (done: Mocha.Done) => { + axios.get(integrationUrl + "/healthcheck/heartbeat").then((res: Response) => { + expect(res.status).to.equal(200); + const data = res.data; + expect(data, "data").to.not.equal(undefined); + expect(data?.s3, "data.s3").to.equal(true); + expect(data?.sqs, "data.sqs").to.equal(true); + expect(data?.failHealthCheck, "data.failHealthCheck").to.equal(undefined); + expect(data?.lastS3Access, "data.lastS3Access").to.equal(undefined); + expect(data?.lastSQSAccess, "data.lastSQSAccess").to.equal(undefined); + expect(data?.ipAddress, "data.ipAddress").to.equal(undefined); + expect(data?.hostname, "data.hostname").to.equal(undefined); + expect(data?.instanceId, "data.instanceId").to.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("/healthcheck/s3", () => { + it("GET healthcheck/s3 should respond 200 OK", (done: Mocha.Done) => { + axios.get(integrationUrl + "/healthcheck/s3").then((res: Response) => { + expect(res.status).to.equal(200); + const data = res.data; + expect(data, "data").to.not.equal(undefined); + expect(data?.s3, "data.s3").to.equal(true); + expect(data?.sqs, "data.sqs").to.equal(undefined); + expect(data?.failHealthCheck, "data.failHealthCheck").to.equal(false); + expect(typeof data?.lastS3Access, "typeof data.lastS3Access").to.equal("string"); + expect(typeof data?.lastSQSAccess, "typeof data.lastSQSAccess").to.equal("string"); + expect(typeof data?.ipAddress, "typeof data.ipAddress").to.equal("string"); + expect(typeof data?.hostname, "typeof data.hostname").to.equal("string"); + expect(typeof data?.instanceId, "typeof data.instanceId").to.equal("string"); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("/healthcheck/sqs", () => { + it("GET healthcheck/sqs should respond 200 OK", (done: Mocha.Done) => { + axios.get(integrationUrl + "/healthcheck/sqs").then((res: Response) => { + expect(res.status).to.equal(200); + const data = res.data; + expect(data, "data").to.not.equal(undefined); + expect(data?.s3, "data.s3").to.equal(undefined); + expect(data?.sqs, "data.sqs").to.equal(true); + expect(data?.failHealthCheck, "data.failHealthCheck").to.equal(false); + expect(typeof data?.lastS3Access, "typeof data.lastS3Access").to.equal("string"); + expect(typeof data?.lastSQSAccess, "typeof data.lastSQSAccess").to.equal("string"); + expect(typeof data?.ipAddress, "typeof data.ipAddress").to.equal("string"); + expect(typeof data?.hostname, "typeof data.hostname").to.equal("string"); + expect(typeof data?.instanceId, "typeof data.instanceId").to.equal("string"); + done(); + }).catch((error) => done(error)); + }); + }); +}); diff --git a/agent/acceptance/tests.spec.ts b/agent/acceptance/tests.spec.ts new file mode 100644 index 00000000..d6716347 --- /dev/null +++ b/agent/acceptance/tests.spec.ts @@ -0,0 +1,70 @@ +import { + LogLevel, + log, + logger, + util +} from "@fs/ppaas-common"; +import axios, { AxiosError, AxiosResponse as Response } from "axios"; +import { expect } from "chai"; + +logger.config.LogFileName = "ppaas-agent"; + +const integrationUrl = "http://" + (process.env.BUILD_APP_URL || `localhost:${process.env.PORT || "8080"}`); +log("integrationUrl = " + integrationUrl); + +describe("Tests Integration", () => { + let url: string; + + before(() => { + url = integrationUrl; + log("smoke tests url=" + url, LogLevel.DEBUG); + }); + + describe(integrationUrl + "/tests/", () => { + it("GET tests/ should respond 404 Not Found", (done: Mocha.Done) => { + axios.get(integrationUrl + "/tests/").then((res: Response) => { + log(integrationUrl + "/tests/", LogLevel.WARN, { status: res.status, data: res.data }); + done(new Error("Should have returned a 404")); + }).catch((error: unknown) => { + log(integrationUrl + "/tests/ error", LogLevel.DEBUG, error, { status: (error as AxiosError)?.response?.status }); + if ((error as AxiosError)?.response?.status === 404){ + done(); + } else { + done(error); + } + }); + }); + }); + + const waitForSuccess = async () => { + let jobId: string | undefined; + try { + const startResponse: Response = await axios.get(integrationUrl + "/tests/build"); + log("startResponse", LogLevel.WARN, { status: startResponse.status, data: startResponse.data }); + if (startResponse.status !== 200) { + throw new Error("start /tests/build returned " + startResponse.status); + } + const data = startResponse.data; + expect(data, "data").to.not.equal(undefined); + expect(data.jobId, "jobId: " + JSON.stringify(data)).to.not.equal(undefined); + expect(typeof data.jobId, "typeof jobId").to.equal("string"); + jobId = startResponse.data.jobId; + log("buildTest jobId: " + jobId, LogLevel.INFO, { jobId, data: startResponse.data }); + const statusUrl = integrationUrl + "/tests/build?jobId=" + jobId; + await util.poll(async () => { + const statusResponse: Response = await axios.get(statusUrl); + log("statusResponse", statusResponse.status === 200 ? LogLevel.WARN : LogLevel.INFO, { status: statusResponse.status, data: statusResponse.data }); + return (statusResponse.status === 200 && statusResponse.data && statusResponse.data.build === true); + }, 120000); // Lighthouse has been taking longer than 60 seconds on build + } catch (error: unknown) { + log("buildTest failed: " + jobId, LogLevel.ERROR, error, { jobId }); + throw error; + } + }; + + describe("/tests/build", () => { + it("GET tests/build should respond 200 OK", (done: Mocha.Done) => { + waitForSuccess().then(() => done()).catch((error) => done(error)); + }); + }); +}); diff --git a/agent/createtest/createtest.sh b/agent/createtest/createtest.sh new file mode 100644 index 00000000..34bb538b --- /dev/null +++ b/agent/createtest/createtest.sh @@ -0,0 +1,3 @@ +#!/bin/bash +export SERVICE_URL_AGENT="127.0.0.1:8081" +# export IGNORED_VARIABLE="This should be ignored" diff --git a/agent/createtest/createtest.yaml b/agent/createtest/createtest.yaml new file mode 100644 index 00000000..27dacf5f --- /dev/null +++ b/agent/createtest/createtest.yaml @@ -0,0 +1,30 @@ +vars: + rampTime: 1m + loadTime: 1m + totalTime: 2m + serviceUrlAgent: ${SERVICE_URL_AGENT} +load_pattern: + - linear: + from: 1% + to: 100% + over: ${rampTime} + - linear: + from: 100% + to: 100% + over: ${loadTime} +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://${serviceUrlAgent}/healthcheck + peak_load: 30hpm diff --git a/agent/createtest/pewpewtest.spec.ts b/agent/createtest/pewpewtest.spec.ts new file mode 100644 index 00000000..b1fb28f1 --- /dev/null +++ b/agent/createtest/pewpewtest.spec.ts @@ -0,0 +1,413 @@ +import { + LogLevel, + MessageType, + PpaasS3File, + PpaasS3Message, + PpaasTestId, + PpaasTestMessage, + PpaasTestStatus, + SqsQueueType, + TestMessage, + TestStatus, + TestStatusMessage, + log, + s3, + sqs, + util +} from "@fs/ppaas-common"; +import { PewPewTest, getEndTime } from "../src/pewpewtest"; +import { basename } from "path"; +import { expect } from "chai"; +import { getHostname } from "../src/util/util"; + +const CREATE_TEST_FILENAME: string = process.env.CREATE_TEST_FILENAME || "createtest.yaml"; +const CREATE_TEST_FILEDIR: string = process.env.CREATE_TEST_FILEDIR || "createtest"; +const CREATE_TEST_SHORTERDIR: string = process.env.CREATE_TEST_SHORTERDIR || "createtest/shorter"; + +describe("PewPewTest Create Test", () => { + let ppaasTestId: PpaasTestId | undefined; + let s3File: PpaasS3File | undefined; + let expectedTestMessage: Required; + let expectedTestStatusMessage: Required; + let ipAddress: string; + let hostname: string; + + before(() => { + sqs.init(); + log("smoke queue url=" + [...sqs.QUEUE_URL_TEST], LogLevel.DEBUG); + + // Prepopulate PpaasTestStatus and make sure all expected data is still there after run + expectedTestStatusMessage = { + instanceId: "bogus", + hostname: "bogus", + ipAddress: "bogus", + startTime: Date.now() - 5000, + endTime: Date.now() - 5000, + resultsFilename: [], + status: TestStatus.Unknown, + errors: [], + version: "bogus", + queueName: "bogus", + userId: "unittestuser" + }; + (expectedTestStatusMessage as TestStatusMessage).errors = undefined; // Set it back to empty so it can get cleared out + try { + ipAddress = util.getLocalIpAddress(); + hostname = getHostname(); + } catch (error) { + log("Could not retrieve ipAddress", LogLevel.ERROR, error); + } + }); + + describe("Get Test from Real SQS Queue", () => { + const createTestFilename = CREATE_TEST_FILENAME; + beforeEach(async () => { + await sqs.cleanUpQueues(); + ppaasTestId = PpaasTestId.makeTestId(createTestFilename); + const s3Folder = ppaasTestId.s3Folder; + s3File = new PpaasS3File({ + filename: createTestFilename, + s3Folder, + localDirectory: CREATE_TEST_FILEDIR + }); + await s3File.upload(); + log("s3File.upload() success", LogLevel.DEBUG); + + // Prepopulate PpaasTestStatus and make sure all expected data is still there after run + const writeResult = await new PpaasTestStatus(ppaasTestId, expectedTestStatusMessage).writeStatus(); + log("PpaasTestStatus.writeStatus() success", LogLevel.DEBUG, { expectedTestStatusMessage, writeResult }); + + expectedTestMessage = { + testId: ppaasTestId.testId, + s3Folder, + yamlFile: createTestFilename, + testRunTimeMn: 2, + version: "latest", + envVariables: { SERVICE_URL_AGENT: "127.0.0.1:8080" }, + restartOnFailure: false, + additionalFiles: [], + bucketSizeMs: 60000, + bypassParser: false, + userId: "unittestuser" + }; + const testMessage: PpaasTestMessage = new PpaasTestMessage(expectedTestMessage); + log("Send Test request", LogLevel.DEBUG, testMessage.sanitizedCopy()); + await testMessage.send(sqs.QUEUE_URL_TEST.keys().next().value); + log("Send Test Success: " + testMessage.toString(), LogLevel.DEBUG); + }); + + afterEach(async () => { + const messagesFound = await sqs.cleanUpQueue(SqsQueueType.Scale); + if (messagesFound > 0) { + const errorMessage: string = `Found test message after test complete: ${messagesFound}`; + log(errorMessage, LogLevel.ERROR); + throw new Error(errorMessage); + } + }); + + it("Retrieve Test and launch should succeed", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + const constructorTestStatusMessage = test!.getTestStatusMessage(); + expect(constructorTestStatusMessage, "getTestStatusMessage()").to.not.equal(undefined); + expect(constructorTestStatusMessage.hostname, "actualTestStatusMessage.hostname").to.equal(hostname); + expect(constructorTestStatusMessage.ipAddress, "actualTestStatusMessage.ipAddress").to.equal(ipAddress); + expect(constructorTestStatusMessage.startTime, "actualTestStatusMessage.startTime").to.be.greaterThan(expectedTestStatusMessage.startTime); + const beforeStartTime = constructorTestStatusMessage.startTime; + expect(constructorTestStatusMessage.endTime, "actualTestStatusMessage.endTime").to.equal(getEndTime(constructorTestStatusMessage.startTime, expectedTestMessage.testRunTimeMn)); + const beforeEndTime = constructorTestStatusMessage.endTime; + expect(Array.isArray(constructorTestStatusMessage.resultsFilename), "Array.isArray actualTestStatusMessage.resultsFilename").to.equal(true); + expect(constructorTestStatusMessage.resultsFilename.length, "actualTestStatusMessage.resultsFilename.length").to.equal(0); + expect(constructorTestStatusMessage.status, "actualTestStatusMessage.status").to.equal(TestStatus.Created); + expect(constructorTestStatusMessage.errors, "actualTestStatusMessage.errors").to.equal(undefined); + expect(constructorTestStatusMessage.version, "actualTestStatusMessage.version").to.equal(expectedTestMessage.version); + expect(constructorTestStatusMessage.queueName, "actualTestStatusMessage.queueName").to.equal(PpaasTestMessage.getAvailableQueueNames()[0]); + expect(constructorTestStatusMessage.userId, "actualTestStatusMessage.userId").to.equal(expectedTestStatusMessage.userId); + + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + // Start and endtime will have updated again. Status will be finished and endTime will be actual endtime + const finishedTestStatusMessage = test!.getTestStatusMessage(); + expect(finishedTestStatusMessage, "getTestStatusMessage()").to.not.equal(undefined); + expect(finishedTestStatusMessage.hostname, "actualTestStatusMessage.hostname").to.equal(hostname); + expect(finishedTestStatusMessage.ipAddress, "actualTestStatusMessage.ipAddress").to.equal(ipAddress); + expect(finishedTestStatusMessage.startTime, "actualTestStatusMessage.startTime").to.be.greaterThan(beforeStartTime); + expect(finishedTestStatusMessage.endTime, "actualTestStatusMessage.endTime").to.be.greaterThan(beforeEndTime); + expect(Array.isArray(finishedTestStatusMessage.resultsFilename), "Array.isArray actualTestStatusMessage.resultsFilename").to.equal(true); + expect(finishedTestStatusMessage.resultsFilename.length, "actualTestStatusMessage.resultsFilename.length").to.equal(1); + expect(finishedTestStatusMessage.status, "actualTestStatusMessage.status").to.equal(TestStatus.Finished); + expect(finishedTestStatusMessage.errors, "actualTestStatusMessage.errors").to.equal(undefined); + expect(finishedTestStatusMessage.version, "actualTestStatusMessage.version").to.equal(expectedTestMessage.version); + expect(finishedTestStatusMessage.queueName, "actualTestStatusMessage.queueName").to.equal(PpaasTestMessage.getAvailableQueueNames()[0]); + expect(finishedTestStatusMessage.userId, "actualTestStatusMessage.userId").to.equal(expectedTestStatusMessage.userId); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it("Retrieve Test and launch, then stop should succeed", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + + // Wait 65 seconds (at least one bucket) then send a stop message + setTimeout(() => { + const stopMessage = new PpaasS3Message({ + testId: ppaasTestId!, + messageType: MessageType.StopTest, + messageData: undefined + }); + stopMessage.send() + .then((messageId: string | undefined) => log("Stop Test MessageId: " + messageId, LogLevel.DEBUG)) + .catch((error) => done(error)); + }, 65000); + + const startTime: number = Date.now(); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + expect(Date.now() - startTime, "Actual Run Time").to.be.lessThan(120000); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it("Retrieve Test and launch, then kill should succeed", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + // Wait 65 seconds (at least one bucket) then send a kill message + setTimeout(() => { + const killMessage = new PpaasS3Message({ + testId: ppaasTestId!, + messageType: MessageType.KillTest, + messageData: undefined + }); + killMessage.send() + .then((messageId: string | undefined) => log("Kill Test MessageId: " + messageId, LogLevel.DEBUG)) + .catch((error) => done(error)); + }, 65000); + + const startTime: number = Date.now(); + try { + await test!.launch(); + // Should have thrown + done(new Error("test.launch() should have failed with pewpew exited with kill")); + } catch(error) { + // Kill should throw + log("'Retrieve Test and launch, then kill should succeed' result", LogLevel.DEBUG, error); + try { + expect(`${error}`, "test.launch() error").to.include("pewpew exited with code null and signal SIGKILL"); + expect(test!.getResultsFile(), "resultsFile").to.not.equal(undefined); + expect(Date.now() - startTime, "Actual Run Time").to.be.lessThan(120000); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + } catch (error2) { + log ("'Retrieve Test and launch, then kill should succeed' error in catch", LogLevel.ERROR, error2); + done(error2); + } + } + }).catch((error) => { + done(error); + }); + }); + + it("Retrieve Test and launch, then update shorter", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + // Wait 65 seconds (at least one bucket) then update shorter + setTimeout(async () => { + s3File = new PpaasS3File({ + filename: createTestFilename, + s3Folder: ppaasTestId!.s3Folder, + localDirectory: CREATE_TEST_SHORTERDIR + }); + await s3File.upload(); + log("s3File.upload() success", LogLevel.DEBUG); + const updateMessage = new PpaasS3Message({ + testId: ppaasTestId!, + messageType: MessageType.UpdateYaml, + messageData: createTestFilename + }); + updateMessage.send() + .then((messageId: string | undefined) => log("Update Yaml MessageId: " + messageId, LogLevel.DEBUG)) + .catch((error) => done(error)); + }, 30000); + + const startTime: number = Date.now(); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + expect(Date.now() - startTime, "Actual Run Time").to.be.lessThan(120000); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }) + .catch((error) => { + done(error); + }); + }); + }); + + describe("Bypass Parser Test", () => { + const createTestFilename = CREATE_TEST_FILENAME; + beforeEach(async () => { + ppaasTestId = PpaasTestId.makeTestId(createTestFilename); + const s3Folder = ppaasTestId.s3Folder; + s3File = new PpaasS3File({ + filename: createTestFilename, + s3Folder, + localDirectory: CREATE_TEST_FILEDIR + }); + await s3File.upload(); + log("s3File.upload() success", LogLevel.DEBUG); + const testMessage: PpaasTestMessage = new PpaasTestMessage({ + testId: ppaasTestId.testId, + s3Folder, + yamlFile: createTestFilename, + version: "latest", + envVariables: { + SERVICE_URL_AGENT: "127.0.0.1:8080", + RUST_LOG: "info", + RUST_BACKTRACE: "full" + }, + restartOnFailure: false, + bypassParser: true + }); + log("Send Test request", LogLevel.DEBUG, testMessage.sanitizedCopy()); + await testMessage.send(sqs.QUEUE_URL_TEST.keys().next().value); + log("Send Test Success: " + testMessage.toString(), LogLevel.DEBUG); + }); + + it("Retrieve Test and launch bypass should succeed", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Retrieve Test and launch bypass, then stop should succeed", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + // Wait 65 seconds (at least one bucket) then send a stop message + setTimeout(() => { + const stopMessage = new PpaasS3Message({ + testId: ppaasTestId!, + messageType: MessageType.StopTest, + messageData: undefined + }); + stopMessage.send() + .then((messageId: string | undefined) => log("Stop Test MessageId: " + messageId, LogLevel.DEBUG)) + .catch((error) => done(error)); + }, 65000); + + const startTime: number = Date.now(); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + expect(Date.now() - startTime, "Actual Run Time").to.be.lessThan(120000); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }) + .catch((error) => { + done(error); + }); + }); + + it("Retrieve Test and launch bypass, then update shorter", (done: Mocha.Done) => { + PewPewTest.retrieve().then(async (test: PewPewTest | undefined) => { + expect(test).to.not.equal(undefined); + expect(test!.getTestId()).to.equal(ppaasTestId!.testId); + expect(test!.getYamlFile()).to.not.equal(undefined); + expect(test!.getResultsFile()).to.equal(undefined); + log("Test retrieved: " + test!.getYamlFile(), LogLevel.DEBUG); + // Wait 65 seconds (at least one bucket) then update shorter + setTimeout(async () => { + s3File = new PpaasS3File({ + filename: createTestFilename, + s3Folder: ppaasTestId!.s3Folder, + localDirectory: CREATE_TEST_SHORTERDIR + }); + await s3File.upload(); + log("s3File.upload() success", LogLevel.DEBUG); + const updateMessage = new PpaasS3Message({ + testId: ppaasTestId!, + messageType: MessageType.UpdateYaml, + messageData: createTestFilename + }); + updateMessage.send() + .then((messageId: string | undefined) => log("Update Yaml MessageId: " + messageId, LogLevel.DEBUG)) + .catch((error) => done(error)); + }, 30000); + + const startTime: number = Date.now(); + await test!.launch(); + expect(test!.getResultsFile()).to.not.equal(undefined); + expect(Date.now() - startTime, "Actual Run Time").to.be.lessThan(120000); + // Validate S3 + const filename: string = basename(test!.getResultsFile()!); + const result = await s3.getObject(`${ppaasTestId!.s3Folder}/${filename}`); + expect(result).to.not.equal(undefined); + expect(result.ContentType).to.equal("application/json"); + done(); + }) + .catch((error) => { + done(error); + }); + }); + }); + +}); diff --git a/agent/createtest/shorter/createtest.yaml b/agent/createtest/shorter/createtest.yaml new file mode 100644 index 00000000..82c6bccf --- /dev/null +++ b/agent/createtest/shorter/createtest.yaml @@ -0,0 +1,26 @@ +vars: + rampTime: 1m + loadTime: 1m + totalTime: 2m + serviceUrlAgent: ${SERVICE_URL_AGENT} +load_pattern: + - linear: + from: 1% + to: 100% + over: ${rampTime} +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://${serviceUrlAgent}/healthcheck + peak_load: 30hpm diff --git a/agent/createtest/tests.spec.ts b/agent/createtest/tests.spec.ts new file mode 100644 index 00000000..c271febd --- /dev/null +++ b/agent/createtest/tests.spec.ts @@ -0,0 +1,12 @@ +import { + buildTest +} from "../src/tests"; +import { logger } from "@fs/ppaas-common"; + +logger.config.LogFileName = "ppaas-agent"; + +describe("Tests Build Test", () => { + it("Should run a build test", (done: Mocha.Done) => { + buildTest({ unitTest: true }).then(() => done()).catch((error) => done(error)); + }); +}); diff --git a/agent/hooks/post-build/000-run-build b/agent/hooks/post-build/000-run-build new file mode 100644 index 00000000..61f02fa0 --- /dev/null +++ b/agent/hooks/post-build/000-run-build @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +source /home/fs/.nvm/nvm.sh +nvm install v18 + +# Environment variables are not passed to our run-build hook/script. Hard code them in the script +export NODE_ENV=production +export LoggingLevel=info +export LoggingLevelConsole=warn + +# Now run whatever node or npm commands you need to run +npm run build diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 00000000..67f2c637 --- /dev/null +++ b/agent/package.json @@ -0,0 +1,61 @@ +{ + "name": "@fs/ppaas-agent", + "version": "3.0.0", + "description": "Agent Service for running pewpew tests", + "main": "dist/src/app.js", + "scripts": { + "fix:start": "rimraf \"node_modules/ppaas-common/node_modules/config-wasm/\"", + "start": "npm run fix:start && node ./dist/src/app.js -r dotenv-flow/config", + "build": "npm run buildonly", + "buildonly": "tsc", + "test": "npm run buildonly && nyc mocha ./dist/test -r dotenv-flow/config", + "testonly": "nyc mocha ./dist/test -r dotenv-flow/config", + "acceptance": "mocha ./dist/acceptance --timeout 130000 -r dotenv-flow/config", + "coverage": "npm run build && nyc mocha ./dist/test ./dist/createtest --timeout 500000 -r dotenv-flow/config", + "integration": "npm run build && nyc mocha --timeout 500000 ./dist/createtest -r dotenv-flow/config", + "clean": "rimraf dist/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/FamilySearch/pewpew.git" + }, + "author": "trevorm@churchofjesuschrist.org", + "bugs": { + "url": "https://github.com/FamilySearch/pewpew/issues" + }, + "homepage": "https://github.com/FamilySearch/pewpew#readme", + "engines": { + "node": "18" + }, + "nyc": { + "exclude": "**/*.spec.ts" + }, + "dependencies": { + "@fs/config-wasm": "*", + "@fs/ppaas-common": "*", + "bunyan": "~1.8.0", + "dotenv": "^16.0.0", + "dotenv-flow": "^3.2.0", + "expiry-map": "^2.0.0", + "express": "^4.18.2", + "rimraf": "^5.0.0" + }, + "devDependencies": { + "@aws-sdk/client-s3": "^3.363.0", + "@aws-sdk/client-sqs": "^3.363.0", + "@aws-sdk/util-stream-node": "^3.363.0", + "@types/bunyan": "~1.8.8", + "@types/chai": "^4.3.5", + "@types/express": "^4.17.17", + "@types/mocha": "^10.0.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "axios": "~1.5.0", + "chai": "^4.3.7", + "eslint": "^8.40.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "typescript": "~5.2.0" + } +} diff --git a/agent/setup.js b/agent/setup.js new file mode 100644 index 00000000..6a35339a --- /dev/null +++ b/agent/setup.js @@ -0,0 +1,4 @@ +"use strict"; +fs = require("fs"); +fs.createReadStream(".sample-env") + .pipe(fs.createWriteStream(".env.local")); \ No newline at end of file diff --git a/agent/src/app.ts b/agent/src/app.ts new file mode 100644 index 00000000..62f6840e --- /dev/null +++ b/agent/src/app.ts @@ -0,0 +1,121 @@ +import "dotenv-flow/config"; +import { IS_RUNNING_IN_AWS, PewPewTest } from "./pewpewtest"; +import { LogLevel, log, logger, util } from "@fs/ppaas-common"; +import { config as serverConfig, start, stop } from "./server"; +import { buildTest } from "./tests"; +import { config as healthcheckConfig } from "./healthcheck"; + +const sleep = util.sleep; + +// We have to set this before we make any log calls +logger.config.LogFileName = "ppaas-agent"; + +start(); +log("PewPewTest.serverStart: " + PewPewTest.serverStart, LogLevel.DEBUG, PewPewTest.serverStart); + +const WARN_IF_NO_MESSAGE_DELAY = parseInt(process.env.WARN_IF_NO_MESSAGE_DELAY || "0", 10) || (15 * 60 * 1000); + +// Start with "Now" so we don't initially fail out. +let lastMessageTime = new Date(); +// Used to shutdown the background process polling for messages +let shutdown: boolean = false; + +process.on("exit", (signal) => { + log("Agent Service process exit", LogLevel.WARN, { signal }); + // eslint-disable-next-line no-console + console.log("process exit"); + shutdown = true; +}); +process.on("SIGUSR1", (signal) => { + log("Agent Service Received SIGUSR1", LogLevel.ERROR, { signal }); + shutdown = true; + stop(); +}); +process.on("SIGUSR2", (signal) => { + log("Agent Service Received SIGUSR2", LogLevel.ERROR, { signal }); + shutdown = true; + stop(); +}); +process.on("SIGINT", (signal) => { + log("Agent Service Received SIGINT", LogLevel.ERROR, { signal }); + shutdown = true; + stop(); +}); +process.on("SIGTERM", (signal) => { + log("Agent Service Received SIGTERM", LogLevel.ERROR, { signal }); + shutdown = true; + stop(); +}); +process.on("unhandledRejection", (e: any) => { + log(`process unhandledRejection: ${e instanceof Error ? e.message : e}`, LogLevel.ERROR, e); +}); +process.on("uncaughtException", (e: any) => { + log(`process uncaughtException: ${e instanceof Error ? e.message : e}`, LogLevel.ERROR, e); + log(`process uncaughtException: ${e instanceof Error ? e.message : e}`, LogLevel.FATAL, e); + shutdown = true; + healthcheckConfig.failHealthCheck = true; + healthcheckConfig.failHealthCheckMessage = e?.message || `${e}`; + stop(); +}); + +(async () => { + // We'll never set this to true unless something really bad happens + healthcheckConfig.failHealthCheck = false; + + // If we're in the build environment, do a basic test to run pewpew and fail the healthcheck if we can't + // ppaas-agent doesn't have a load balancer so we can't run this as part of acceptance + if (process.env.FS_SYSTEM_NAME === "build") { + await buildTest(); + } + + while (!shutdown) { + try { + serverConfig.testToRun = await PewPewTest.retrieve(); + } catch (error) { + log("Error trying to get new test", LogLevel.ERROR, error); + await sleep(5000); + } + if (serverConfig.testToRun) { + const logObject = { + ...serverConfig.testToRun.sanitizedCopy(), + testId: serverConfig.testToRun.getTestId(), + yamlFile: serverConfig.testToRun.getYamlFile(), + userId: serverConfig.testToRun.getUserId() + }; + lastMessageTime = new Date(); + log(`New test received ${serverConfig.testToRun.getTestId()}`, LogLevel.INFO, { ...logObject, lastMessageTime }); + if (!IS_RUNNING_IN_AWS) { + // eslint-disable-next-line no-console + console.log(`${serverConfig.testToRun.getTestId()}: New test received at ${lastMessageTime} for ${serverConfig.testToRun.getYamlFile()}}`); + } + // Process message and start a test + const startTest = Date.now(); + try { + await serverConfig.testToRun.launch(); + log(`Test Complete ${serverConfig.testToRun.getTestId()}`, LogLevel.INFO, logObject); + if (!IS_RUNNING_IN_AWS) { + // eslint-disable-next-line no-console + console.log(`${serverConfig.testToRun.getTestId()}: Test Complete after ${(Date.now() - startTest) / 1000}s`); + } + } catch (error) { + log(`Error running test ${serverConfig.testToRun.getTestId()}`, LogLevel.ERROR, error, logObject); + // Report to Controller + await sleep(5000); // Final results still may be uploading + serverConfig.testToRun = undefined; + } + } else { + log(`No test received at ${lastMessageTime}`, LogLevel.DEBUG); + if (Date.now() - lastMessageTime.getTime() > WARN_IF_NO_MESSAGE_DELAY) { + log(`No new test to run since ${lastMessageTime}`, LogLevel.WARN); + } + } + } + log(`shutdown=${shutdown}. Shutting Down.`, LogLevel.INFO); + healthcheckConfig.failHealthCheckMessage = `shutdown=${shutdown}. Shutting Down.`; + healthcheckConfig.failHealthCheck = true; +})().catch((err) => { + log("Error during Run", LogLevel.FATAL, err); + healthcheckConfig.failHealthCheck = true; + healthcheckConfig.failHealthCheckMessage = err?.message || `${err}`; + stop(); +}); diff --git a/agent/src/healthcheck.ts b/agent/src/healthcheck.ts new file mode 100644 index 00000000..5f83c31c --- /dev/null +++ b/agent/src/healthcheck.ts @@ -0,0 +1,119 @@ +import { LogLevel, ec2, log, s3, sqs } from "@fs/ppaas-common"; +import { NextFunction, Request, Response, Router } from "express"; +import express from "express"; +import { getHostname } from "./util/util"; +import { util } from "@fs/ppaas-common"; + +const listObjects = s3.listObjects; +const setS3AccessCallback = s3.setAccessCallback; +const setSqsAccessCallback = sqs.setAccessCallback; + +// Export these for testing +export const S3_ALLOWED_LAST_ACCESS_MS: number = parseInt(process.env.S3_ALLOWED_LAST_ACCESS_MS || "0", 10) || 90000; +export const SQS_ALLOWED_LAST_ACCESS_MS: number = parseInt(process.env.SQS_ALLOWED_LAST_ACCESS_MS || "0", 10) || 90000; + +export interface HealthCheckConfig { + lastS3Access: Date; + lastSQSAccess: Date; + failHealthCheck: boolean; + failHealthCheckMessage?: string; + ipAddress?: string; + hostname?: string; + instanceId?: string; +} + +export const config: HealthCheckConfig = { + lastS3Access: new Date(0), + lastSQSAccess: new Date(0), + failHealthCheck: false +}; + +try { + ec2.getInstanceId() + .then((instanceId) => config.instanceId = instanceId) + .catch((error) => log("Could not retrieve instanceId", LogLevel.ERROR, error)); + config.ipAddress = util.getLocalIpAddress(); + config.hostname = getHostname(); +} catch (error) { + log("Could not retrieve ipAddress", LogLevel.ERROR, error); +} + +const lastS3AccessCallBack = (date: Date) => config.lastS3Access = date; +const lastSQSAccessCallBack = (date: Date) => config.lastSQSAccess = date; +setS3AccessCallback(lastS3AccessCallBack); +setSqsAccessCallback(lastSQSAccessCallBack); + +// These are in separate export functions for test purposes +export function accessS3Pass (lastS3Access: Date): boolean { + const s3Pass: boolean = ((Date.now() - lastS3Access.getTime()) < S3_ALLOWED_LAST_ACCESS_MS); + log("accessS3Pass", LogLevel.DEBUG, { now: Date.now(), lastS3Access: lastS3Access.getTime(), s3Pass }); + return s3Pass; +} + +export function accessSqsPass (lastSQSAccess: Date): boolean { + const sqsPass: boolean = ((Date.now() - lastSQSAccess.getTime()) < SQS_ALLOWED_LAST_ACCESS_MS); + log("accessSqsPass", LogLevel.DEBUG, { now: Date.now(), lastSQSAccess: lastSQSAccess.getTime(), sqsPass }); + return sqsPass; +} + +// Shared function for the S3 healthcheck and normal healthcheck if accessS3Pass fails +export async function pingS3 (): Promise { + log("Pinging S3 at " + new Date(), LogLevel.DEBUG); + // Ping S3 and update the lastS3Access if it works + try { + await listObjects("ping"); // Limit 1 so we can get back fast + config.lastS3Access = new Date(); + log("Pinging S3 succeeded at " + new Date(), LogLevel.DEBUG); + return true; + } catch(error) { + log("pingS3 failed}", LogLevel.ERROR, error); + // DO NOT REJECT. Just return false + return false; + } +} + +export function init (): Router { + const router: Router = express.Router(); + // middleware that is specific to this router + router.use((req: Request, res: Response, next: NextFunction) => { + log(`originalUrl:${req.originalUrl}`, LogLevel.DEBUG); + res.type("application/json"); + next(); + }); + // define the home page route + router.get("/", async (_req: Request, res: Response) => { + if (config.failHealthCheck) { + log("healthcheck", LogLevel.WARN, config); + res.status(500).json(config); + } else { + const s3Pass: boolean = accessS3Pass(config.lastS3Access) || (await pingS3()); + const sqsPass: boolean = accessSqsPass(config.lastSQSAccess); + log("healthcheck", LogLevel.DEBUG, { ...config, s3Pass, sqsPass }); + res.status(s3Pass && sqsPass ? 200 : 500).json({ ...config, s3: s3Pass || false, sqs: sqsPass || false }); + } + }); + // define the heartbeat route + router.get("/heartbeat", async (_req: Request, res: Response) => { + if (config.failHealthCheck) { + log("heartbeat", LogLevel.WARN, config); + res.status(500).json(config); + } else { + const s3Pass: boolean = accessS3Pass(config.lastS3Access) || (await pingS3()); + const sqsPass: boolean = accessSqsPass(config.lastSQSAccess); + log("heartbeat", LogLevel.DEBUG, { ...config, s3Pass, sqsPass }); + res.status(s3Pass && sqsPass ? 200 : 500).json({ s3: s3Pass || false, sqs: sqsPass || false }); + } + }); + // define the s3 route + router.get("/s3", async (_req: Request, res: Response) => { + const s3Pass: boolean = accessS3Pass(config.lastS3Access) || (await pingS3()); + res.status(s3Pass ? 200 : 500).json({ ...config, s3: s3Pass || false }); + }); + // define the sqs route + router.get("/sqs", (_req: Request, res: Response) => { + const sqsPass: boolean = accessSqsPass(config.lastSQSAccess); + res.status(sqsPass ? 200 : 500).json({ ...config, sqs: sqsPass || false }); + }); + + return router; +} diff --git a/agent/src/pewpewtest.ts b/agent/src/pewpewtest.ts new file mode 100644 index 00000000..cc51aebc --- /dev/null +++ b/agent/src/pewpewtest.ts @@ -0,0 +1,945 @@ +import { ChildProcess, spawn } from "child_process"; +import { + LogLevel, + MessageType, + PpaasCommunicationsMessage, + PpaasS3File, + PpaasS3Message, + PpaasTestId, + PpaasTestMessage, + PpaasTestStatus, + TestMessage, + TestStatus, + TestStatusMessage, + YamlParser, + ec2, + log, + logger, + sqs, + util +} from "@fs/ppaas-common"; +import { WriteStream, createWriteStream } from "fs"; +import fs from "fs/promises"; +import { getHostname } from "./util/util"; +import { join as pathJoin } from "path"; +import { platform } from "os"; + +logger.config.LogFileName = "ppaas-agent"; +const logConfig = logger.config; +const { deleteTestScalingMessage, refreshTestScalingMessage } = sqs; +const { poll, sleep } = util; +const createStatsFileName = util.createStatsFileName; + +const DEFAULT_PEWPEW_PARAMS = [ + "run", + "-f", "json", + "-w" +]; + +const PEWPEW_PATH: string = process.env.PEWPEW_PATH || "pewpew"; +const DOWNLOAD_PEWPEW: boolean = (process.env.DOWNLOAD_PEWPEW || "false") === "true"; +const LOCAL_FILE_LOCATION: string = process.env.LOCAL_FILE_LOCATION || process.env.TEMP || "/tmp"; +const RESULTS_FILE_MAX_WAIT: number = parseInt(process.env.RESULTS_FILE_MAX_WAIT || "0", 10) || 5000; // S3_UPLOAD_INTERVAL + this Could take up to a minute +const KILL_MAX_WAIT: number = parseInt(process.env.KILL_MAX_WAIT || "0", 10) || 180000; // How long to wait between SIGINT (Ctrl-C) and SIGKILL +const ALLOW_TEST_OVERAGE: number = parseInt(process.env.ALLOW_TEST_OVERAGE || "0", 10) || 300000; +const SPLUNK_FORWARDER_EXTRA_TIME: number = parseInt(process.env.SPLUNK_FORWARDER_EXTRA_TIME || "0", 10) || 10000; +/** Have to run for at least 5 minutes for us to restart */ +const MIN_RUNTIME_FOR_RETRY: number = parseInt(process.env.MIN_RUNTIME_FOR_RETRY || "0", 10) || 300000; +export const IS_RUNNING_IN_AWS: boolean = process.env.APPLICATION_NAME !== undefined && process.env.SYSTEM_NAME !== undefined; +/** Wait to start the test to make sure we don't scale in (ms) */ +const TEST_START_DELAY_MIN_UPTIME: number = parseInt(process.env.TEST_START_DELAY_MIN_UPTIME || "0", 10) || (IS_RUNNING_IN_AWS ? 240000 : 30000); +/** Wait to start the test to make sure we don't scale in (ms) */ +const TEST_START_DELAY_FOR_SCALE: number = parseInt(process.env.TEST_START_DELAY_FOR_SCALE || "0", 10) || (IS_RUNNING_IN_AWS ? 120000 : 3000); +/** Refresh our message on the queue (ms) */ +const TEST_START_SLEEP_FOR_SCALE: number = parseInt(process.env.TEST_START_SLEEP_FOR_SCALE || "0", 10) || (IS_RUNNING_IN_AWS ? 30000 : 3000); +// How long should we sleep before we try to get another message if we get none and it comes back too fast +const COMMUCATION_NO_MESSAGE_DELAY: number = parseInt(process.env.COMMUCATION_NO_MESSAGE_DELAY || "0", 10) || (10 * 1000); +const VERSION_SPECIFIC_RESULTS_FILE: string = "0.5.4"; +const VERSION_RESTART_TEST_AT_TIME: string = "0.5.5"; +const TEN_MINUTES: number = 10 * 60000; + +// Export for testing +export async function findYamlCreatedFiles (localPath: string, yamlFile: string, additionalFiles: string[] | undefined): Promise { + try { + log(`findYamlCreatedFiles: ${localPath}}`, LogLevel.DEBUG); + const files = await fs.readdir(localPath); + log(`findYamlCreatedFiles files: ${files}}`, LogLevel.DEBUG); + if (files) { + additionalFiles = additionalFiles || []; + // Find files that aren't the yamlFile or additionalFile, a results file, or the pewpew executable + const YamlCreatedFiles = files.filter((file) => file !== yamlFile && !additionalFiles!.includes(file) + && !(file.startsWith("stats-") && file.endsWith(".json")) && file !== "pewpew" && file !== "pewpew.exe"); + log(`YamlCreatedFiles: ${YamlCreatedFiles}}`, LogLevel.DEBUG); + if (YamlCreatedFiles.length > 0) { + return YamlCreatedFiles; // Don't return the joined path + } + } + return undefined; + } catch (error) { + log("Could not Find Yaml Created Files", LogLevel.ERROR, error); + throw error; + } +} + +// Export for testing +export function versionGreaterThan (currentVersion: string, compareVersion: string): boolean { + // If the current version is latest then we're always greater than or equal to + if (currentVersion === "latest") { return true; } + // If the compareVersion is latest, then only currrentVersion=latest is greater + if (compareVersion === "latest") { return false; } + // compareVersion cannot be a beta; + if (!/^\d+\.\d+\.\d+$/.test(compareVersion)) { + throw new Error("compareVersion cannot be a beta"); + } + const currentMatch: string[] | null = currentVersion.match(/^(\d+\.\d+\.\d+)(.*)/); + // If we can't find a version string 0.0.0 at the beginning, we don't know + if (!currentMatch) { + return false; + } + const version = currentMatch[1]; + const beta = currentMatch[2]; + return beta !== "" ? version > compareVersion : version >= compareVersion; +} + +// Export for testing +export const getEndTime = (startTime: number, testRunTimeMn: number) => startTime + (testRunTimeMn * 60000); + +// Export for testing +export function copyTestStatus (ppaasTestStatus: PpaasTestStatus, s3Status: TestStatusMessage | undefined, overwriteStatus: TestStatusMessage) { + // Future proof if we add new things. Copy all properties over, then copy the new ones we created in. + Object.assign(ppaasTestStatus, s3Status || {}, overwriteStatus); +} + +/** instanceId doesn't change, so we'll save it after the first call as the instanceId or an Error */ +let instanceId: string | Error | undefined; +async function getInstanceId (): Promise { + if (typeof instanceId === "string") { + log("instanceId string: " + instanceId, LogLevel.DEBUG, instanceId); + return instanceId; + } + if (instanceId instanceof Error) { + log("instanceId instanceof Error", LogLevel.DEBUG, instanceId); + throw instanceId; + } + + try { + log("instanceId getInstanceId", LogLevel.DEBUG, instanceId); + instanceId = await ec2.getInstanceId(); + log("instanceId new string: " + instanceId, LogLevel.DEBUG, instanceId); + } catch (error) { + instanceId = error; + log("instanceId new Error: " + instanceId, LogLevel.DEBUG, instanceId); + throw instanceId; + } + return instanceId; +} +if (IS_RUNNING_IN_AWS) { + getInstanceId().catch((error: unknown) => log("Could not retrieve instanceId", LogLevel.ERROR, error)); +} + +export class PewPewTest { + protected resultsFileMaxWait: number; + protected started: boolean = false; + protected pewpewRunning: boolean = false; + protected uploadRunning: boolean = false; + protected communicationsRunning: boolean = false; + protected stopCalled: boolean = false; + protected testMessage: PpaasTestMessage; + protected ppaasTestId: PpaasTestId; + protected ppaasTestStatus: PpaasTestStatus; + protected startTime: Date | undefined; + protected testEnd: number | undefined; + protected pewpewEnd: number | undefined; + protected localPath: string; + protected pewpewProcess: ChildProcess | undefined; + protected iteration: number; + protected yamlS3File: PpaasS3File; + protected additionalS3Files: PpaasS3File[] | undefined; + protected pewpewStdOutS3File: PpaasS3File | undefined; + protected pewpewStdErrS3File: PpaasS3File | undefined; + protected pewpewResultsS3File: PpaasS3File | undefined; + public static readonly serverStart: Date = new Date(); + + public constructor (testMessage: PpaasTestMessage) { + if (!testMessage.testId || !testMessage.s3Folder || !testMessage.yamlFile || (!testMessage.testRunTimeMn && !testMessage.bypassParser)) { + log("testData was missing data", LogLevel.ERROR, testMessage.sanitizedCopy()); + throw new Error("New Test Message was missing testId, s3Folder, yamlFile, or testRunTime"); + } + this.resultsFileMaxWait = testMessage.bucketSizeMs + RESULTS_FILE_MAX_WAIT; + this.testMessage = testMessage; + // Remove any invalid file characters from testId, or just allow letters, numbers, and dash/underscore + this.testMessage.testId = this.testMessage.testId.replace(/[^\w\d-_]/g, ""); + this.ppaasTestId = PpaasTestId.getFromTestId(this.testMessage.testId); + let ipAddress: string | undefined; + let hostname: string | undefined; + try { + ipAddress = util.getLocalIpAddress(); + hostname = getHostname(); + } catch (error) { + log("Could not retrieve ipAddress", LogLevel.ERROR, error); + } + // Save this data to put back in after we read the current info from s3 on delay + const newTestStatus: TestStatusMessage = { + startTime: Date.now(), + endTime: getEndTime(Date.now(), this.testMessage.testRunTimeMn || 60), + resultsFilename: [], + status: TestStatus.Created, + queueName: PpaasTestMessage.getAvailableQueueNames()[0], // Length will be 1 on the agents + version: testMessage.version, + ipAddress, + hostname, + userId: testMessage.userId + }; + this.ppaasTestStatus = new PpaasTestStatus(this.ppaasTestId, newTestStatus); + const s3Folder = this.testMessage.s3Folder; + const localDirectory = this.localPath = pathJoin(LOCAL_FILE_LOCATION, this.testMessage.testId); + this.yamlS3File = new PpaasS3File({ filename: this.testMessage.yamlFile, s3Folder, localDirectory }); + if (testMessage.additionalFiles && testMessage.additionalFiles.length > 0) { + this.additionalS3Files = testMessage.additionalFiles.map((filename: string) => + new PpaasS3File({ filename, s3Folder, localDirectory })); + } + this.iteration = 0; + // Load future additions on delay so we don't overwrite with undefined + PpaasTestStatus.getStatus(this.ppaasTestId) + .then((s3TestStatus: PpaasTestStatus | undefined) => { + if (s3TestStatus) { + copyTestStatus(this.ppaasTestStatus, s3TestStatus.getTestStatusMessage(), newTestStatus); + log(`PpaasTestStatus.getStatus(${this.testMessage.testId}) found status`, LogLevel.DEBUG, { + s3TestStatus: s3TestStatus.getTestStatusMessage(), + newTestStatus, + ppaasTestStatus: this.ppaasTestStatus.getTestStatusMessage() + }); + } else { + throw new Error(`PpaasTestStatus.getStatus(${this.testMessage.testId}) returned undefined`); + } + }) + .catch((error: any) => log("Could not retrieve PpaasTestStatus from s3 to save previous data", LogLevel.ERROR, error)); + getInstanceId() + .then((instanceId: string) => { + log("instanceId: " + instanceId, LogLevel.DEBUG); + newTestStatus.instanceId = this.ppaasTestStatus.instanceId = instanceId; + }) + .catch((error: unknown) => log("Could not retrieve instanceId", LogLevel.INFO, error)); + } + + // Create a sanitized copy which doesn't have the environment variables which may have passwords + // eslint-disable-next-line @typescript-eslint/ban-types + public sanitizedCopy (): { + started: boolean, + pewpewRunning: boolean, + uploadRunning: boolean, + communicationsRunning: boolean, + stopCalled: boolean, + testMessage: Omit & { envVariables: string[] }, + ppaasTestStatus: TestStatusMessage, + startTime: Date | undefined, + testEnd: number | undefined, + pewpewEnd: number | undefined, + localPath: string, + pewpewProcess: ChildProcess | undefined, + iteration: number, + yamlS3File: string, + additionalS3Files: string[] | undefined, + pewpewStdOutS3File: string | undefined, + pewpewStdErrS3File: string | undefined, + pewpewResultsS3File: string | undefined, + serverStart: Date + } { + return { + started: this.started, + pewpewRunning: this.pewpewRunning, + uploadRunning: this.uploadRunning, + communicationsRunning: this.communicationsRunning, + stopCalled: this.stopCalled, + testMessage: this.testMessage.sanitizedCopy(), + ppaasTestStatus: this.ppaasTestStatus.sanitizedCopy(), + startTime: this.startTime, + testEnd: this.testEnd, + pewpewEnd: this.pewpewEnd, + localPath: this.localPath, + pewpewProcess: this.pewpewProcess, + iteration: this.iteration, + yamlS3File: this.yamlS3File.remoteUrl, + additionalS3Files: this.additionalS3Files ? this.additionalS3Files.map((file) => file.remoteUrl) : undefined, + pewpewStdOutS3File: this.pewpewStdOutS3File?.remoteUrl, + pewpewStdErrS3File: this.pewpewStdErrS3File?.remoteUrl, + pewpewResultsS3File: this.pewpewResultsS3File?.remoteUrl, + serverStart: PewPewTest.serverStart + }; + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + public getStarted (): boolean { + return this.started; + } + + public getRunning (): boolean { + return this.pewpewRunning; + } + + public getTestId (): string | undefined { + return this.testMessage.testId; + } + + public getYamlFile (): string | undefined { + return this.testMessage.yamlFile; + } + + public getUserId (): string | undefined { + return this.testMessage.userId || this.ppaasTestStatus.userId; + } + + public getResultsFile (): string | undefined { + return this.pewpewResultsS3File && this.pewpewResultsS3File.localFilePath; + } + + public getResultsFileS3 (): string | undefined { + return this.pewpewResultsS3File && this.pewpewResultsS3File.remoteUrl; + } + + public getTestStatusMessage () { + return this.ppaasTestStatus.getTestStatusMessage(); + } + + // Log wrapper that adds the testId + protected log (message: string, level?: LogLevel, ...datas: any[]) { + log(message, level, ...datas, { testId: this.testMessage.testId, yamlFile: this.testMessage.yamlFile }); + } + + protected async writeTestStatus () { + try { + await this.ppaasTestStatus.writeStatus(); + } catch(error) { + this.log("Could not write ppaasTestStatus", LogLevel.ERROR, error, { ppaasTestStatus: this.ppaasTestStatus }); + } + } + + protected async sendTestStatus (messageType: MessageType = MessageType.TestStatus) { + const messageData: TestStatusMessage = this.ppaasTestStatus.getTestStatusMessage(); + try { + const { testId } = this.testMessage; + const messageId = await new PpaasCommunicationsMessage({ testId, messageType, messageData }).send(); + this.log(`Sent testStatus ${messageType}: " ${messageId}`, LogLevel.DEBUG, { messageData, messageId }); + } catch(error) { + this.log("Could not send TestStatus", LogLevel.ERROR, error, { messageData }); + } + } + + protected async refreshTestScalingMessage () { + try { + const messageId = await refreshTestScalingMessage(); + this.log(`Sent refreshTestScalingMessage: " ${messageId}`, LogLevel.DEBUG, { messageId }); + } catch(error) { + this.log("Error calling refreshTestScalingMessage", LogLevel.ERROR, error); + } + } + + protected async deleteTestScalingMessage () { + try { + const messageId = await deleteTestScalingMessage(); + this.log(`deleteTestScalingMessage: " ${messageId}`, LogLevel.DEBUG, { messageId }); + } catch(error) { + this.log("Error calling deleteTestScalingMessage", LogLevel.ERROR, error); + } + } + + /** * Retrieves a test from the SQS queue and returns a Test object ** */ + public static async retrieve (): Promise { + const ppaasTestMessage: PpaasTestMessage | undefined = await PpaasTestMessage.getNewTestToRun(); + log(`getNewTestToRun() at ${Date.now()}`, LogLevel.DEBUG, ppaasTestMessage && ppaasTestMessage.sanitizedCopy()); + if (!ppaasTestMessage) { return undefined; } + const newTest = new this(ppaasTestMessage); + return newTest; + } + + /** * Launches the pewpew process and waits for it to complete, throws on error ** */ + public async launch (): Promise { + try { + // Keep alive + await this.refreshTestScalingMessage(); + // Create Local Path + try { + await fs.mkdir(this.localPath); // What to do if it already exists? + } catch (error) { + if (!error || error.code !== "EEXIST") { + throw error; + } + } + this.log(`localPath created = ${this.localPath}`, LogLevel.DEBUG); + + // Download file(s) + const yamlLocalPath: string = await this.yamlS3File.download(true); + this.log(`getFile(${this.testMessage.yamlFile}) result = ${yamlLocalPath}`, LogLevel.DEBUG); + // Always call this even if it isn't logged to make sure the file downloaded + this.log(`fs.access(${yamlLocalPath}) stats = ${JSON.stringify(await fs.access(yamlLocalPath))}`, LogLevel.DEBUG); + + let pewpewPath = PEWPEW_PATH; + // Download the pewpew executable if needed + if (DOWNLOAD_PEWPEW) { + // version check in the test message + const version = this.testMessage.version || "latest"; + const localDirectory = this.localPath; + const s3Folder = "pewpew/" + version; + this.log(`os.platform() = ${platform()}`, LogLevel.DEBUG, { version, s3Folder }); + if (platform() === "win32") { + const pewpewS3File: PpaasS3File = new PpaasS3File({ + filename: "pewpew.exe", + s3Folder, + localDirectory + }); + pewpewPath = await pewpewS3File.download(true); + this.log(`getFile(pewpew.exe) result = ${pewpewPath}`, LogLevel.DEBUG); + } else { + // If the version isn't there, this will throw (since we can't find it) + const pewpewS3File: PpaasS3File = new PpaasS3File({ + filename: "pewpew", + s3Folder, + localDirectory + }); + pewpewPath = await pewpewS3File.download(true); + this.log(`getFile(pewpew) result = ${pewpewPath}`, LogLevel.DEBUG); + // We need to make it executable + await fs.chmod(pewpewPath, 0o775); + } + // Always call this even if it isn't logged to make sure the file downloaded + this.log(`fs.stat(${pewpewPath}) access = ${JSON.stringify(await fs.access(pewpewPath))}`, LogLevel.DEBUG); + } + + // Download Additional Files after pewpew so we can push custom pewpew binaries as part of our tests (if needed) + if (this.additionalS3Files && this.additionalS3Files.length > 0) { + for (const s3File of this.additionalS3Files) { + const additionalFile: string = await s3File.download(true); + this.log(`getFile(${s3File.localFilePath}) result = ${additionalFile}`, LogLevel.DEBUG); + // Always call this even if it isn't logged to make sure the file downloaded + this.log(`fs.access(${additionalFile}) stats = ${JSON.stringify(await fs.access(additionalFile))}`, LogLevel.DEBUG); + } + } + + // Create the log files + const pewpewParams = [...DEFAULT_PEWPEW_PARAMS, "-d", this.localPath, yamlLocalPath]; + // Splunk log, Stdout, and Stderr going to S3. Write to Splunk directory then upload to S3 + // These must be named app*.json + const s3Folder = this.testMessage.s3Folder; + this.pewpewStdOutS3File = new PpaasS3File({ + filename: logger.pewpewStdOutFilename(this.testMessage.testId), + s3Folder, + localDirectory: logConfig.LogFileLocation + }); + this.log(`pewpewStdOutFilename = ${this.pewpewStdOutS3File.localFilePath}`, LogLevel.DEBUG); + this.pewpewStdErrS3File = new PpaasS3File({ + filename: logger.pewpewStdErrFilename(this.testMessage.testId), + s3Folder, + localDirectory: logConfig.LogFileLocation + }); + this.log(`pewpewStdErrS3File = ${this.pewpewStdErrS3File.localFilePath}`, LogLevel.DEBUG); + + // Don't clone the current environment for security. Create a new environment only with the test environment variables + // Create a few variations of SPLUNK_PATH/SPLUNK_LOCATION for tests to use + for (const splunkPath of logger.PEWPEW_SPLUNK_INJECTED_VARIABLES) { + this.testMessage.envVariables[splunkPath] = logConfig.LogFileLocation; + } + if (this.testMessage.envVariables["RUST_BACKTRACE"] === undefined) { + this.testMessage.envVariables.RUST_BACKTRACE = "1"; + } + if (this.testMessage.envVariables["RUST_LOG"] === undefined) { + this.testMessage.envVariables.RUST_LOG = "warn"; + } + this.log("envVariables", LogLevel.DEBUG, Object.keys(this.testMessage.envVariables)); + this.log("envVariables", LogLevel.TRACE, this.testMessage.envVariables); + // There is still a race condition where this ec2 instance is slated for scale in + // But hasn't scaled in yet. We get like 30 seconds of run, then the instance dies. + // We'll sleep for 90 seconds, but refresh our lockout every 30 seconds + const testDelayStart: number = Date.now(); + const serverUptime: number = testDelayStart - PewPewTest.serverStart.getTime(); + // Check if the machine just came up and don't delay. + log("testDelayStart", LogLevel.DEBUG, { testDelayStart, serverUptime, date: new Date(testDelayStart), serverStart: PewPewTest.serverStart, TEST_START_DELAY_MIN_UPTIME, TEST_START_DELAY_FOR_SCALE }); + let loopCount: number = 0; + // If the server has been up less than 5 minutes/TEST_START_DELAY_MIN_UPTIME, don't wait + // If the server has been up more than TEST_START_DELAY_MIN_UPTIME, then sleep (and refresh) TEST_START_DELAY_FOR_SCALE + while (serverUptime > TEST_START_DELAY_MIN_UPTIME && Date.now() - testDelayStart < TEST_START_DELAY_FOR_SCALE) { + await sleep(TEST_START_SLEEP_FOR_SCALE); + await this.refreshTestScalingMessage(); + await this.testMessage.extendMessageLockout(); + log("refreshTestScalingMessage && extendMessageLockout loop " + loopCount++, LogLevel.DEBUG); + } + log("refreshTestScalingMessage && extendMessageLockout end", LogLevel.DEBUG, { testDelayStart, testDelayEnd: Date.now(), diff: Date.now() - testDelayStart}); + this.startTime = new Date(); + this.testEnd = getEndTime(this.startTime.getTime(), this.testMessage.testRunTimeMn || 60); + this.ppaasTestStatus.startTime = this.startTime.getTime(); + this.ppaasTestStatus.endTime = this.testEnd; + const minTimeForRetry = this.startTime.getTime() + MIN_RUNTIME_FOR_RETRY; + this.started = true; + let pewpewRestart; + do { + this.pewpewRunning = true; + pewpewRestart = false; + // Launch the test + const pewpewPromise = new Promise((resolve, reject) => { + // Add named results file and restart at position + const pewpewParamsThisRun: string[] = [...pewpewParams]; + if (versionGreaterThan(this.testMessage.version, VERSION_SPECIFIC_RESULTS_FILE)) { + pewpewParamsThisRun.push("-o", createStatsFileName(this.testMessage.testId, this.iteration)); + } + // If we're on a second run through, and we have a version that supports it, start at x seconds + if (this.iteration > 0 && versionGreaterThan(this.testMessage.version, VERSION_RESTART_TEST_AT_TIME)) { + pewpewParamsThisRun.push("-t", `${Math.round((Date.now() - this.startTime!.getTime()) / 1000)}s`); + } + const pewpewOutStream: WriteStream = createWriteStream(this.pewpewStdOutS3File!.localFilePath, { flags: "a" }); + const pewpewErrorStream: WriteStream = createWriteStream(this.pewpewStdErrS3File!.localFilePath, { flags: "a" }); + this.log(`Running ${this.iteration}: ${pewpewPath} ${pewpewParamsThisRun.join(" ")}`, LogLevel.DEBUG, pewpewParams); + const pewpewProcess = spawn(pewpewPath, pewpewParamsThisRun, { cwd: this.localPath, env: this.testMessage.envVariables }) + .on("error", (e: any) => { + this.log(`pewpew error: ${e instanceof Error ? e.message : e}`, LogLevel.ERROR, e); + this.internalStop().catch((error) => this.log("error stopping", LogLevel.ERROR, error)); + reject(e); + }) + .on("exit", (code: number, signal: string) => { + this.pewpewRunning = false; + this.pewpewEnd = Date.now(); + const message = `pewpew exited with code ${code} and signal ${signal}`; + if (code !== 0) { + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), message]; + } + if (code === 0 || signal === "SIGINT") { + this.log(message, signal === "SIGINT" ? LogLevel.WARN : LogLevel.INFO); + // We still want to resolve on SIGINT (stop called) so we don't get errors down the line. Just log one warning here + resolve(); + } else { + this.log(message, LogLevel.ERROR); + reject(message); + } + this.pewpewProcess = undefined; + try { // Close the streams + pewpewOutStream.end(); + pewpewErrorStream.end(); + } catch (error) { + this.log("stream.end() failed for pewpew out or error write file stream", LogLevel.ERROR, error); + } + }); + this.pewpewProcess = pewpewProcess; + // Wait for open events + pewpewOutStream.on("open", () => pewpewProcess.stdout.pipe(pewpewOutStream)); // To Splunk and S3 + pewpewErrorStream.on("open", () => pewpewProcess.stderr.pipe(pewpewErrorStream)); // To Splunk and S3 + // We want pewpew to die if we die. It's better to restart the test than to have an orphaned process that isn't being uploaded to s3 + // pewpewProcess.unref(); // Allows the parent (this) to exit without waiting for pewpew to exit + this.log(`Removing Start Test Message from queue ${this.testMessage.receiptHandle}`, LogLevel.DEBUG); + this.testMessage.deleteMessageFromQueue().catch((error) => this.log(`Could not remove Start Test message from from queue: ${this.testMessage.receiptHandle}`, LogLevel.ERROR, error)); + }); + const promises = [pewpewPromise, this.pollAndUploadResults(), this.pollCommunications()]; + try { + await Promise.all(promises); + if (!this.stopCalled && (Date.now() < this.testEnd - 60000) && !this.testMessage.bypassParser) { + // If we're less than a minute before what should be the end and we exited gracefully, log it + const message = "Pewpew exited gracefully early without stop being called. Check the loggers and providers."; + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), message]; + this.log(message, LogLevel.WARN); + } + } catch (error) { + if (!this.stopCalled && this.testMessage.restartOnFailure && Date.now() > minTimeForRetry && (this.testMessage.bypassParser || Date.now() < this.testEnd)) { + // log it, but continue + const errorMessage = `launch test error: ${error && error.message ? error.message : error}, restartOnFailure: ${this.testMessage.restartOnFailure}`; + this.log(errorMessage, LogLevel.ERROR, error); + // Send error communications message + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), errorMessage]; + await Promise.all([ + this.sendTestStatus(MessageType.TestError), + this.writeTestStatus() // write error status + ]); + if (this.pewpewRunning) { + // Call stop and wait to restart + await this.internalStop(); + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewProcess || !this.pewpewRunning; + }, 5000, (errMsg: string) => `${errMsg} Could not stop PewPew. Can't restartOnFailure`); + } + if (this.uploadRunning) { + // Wait for it to finish + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.uploadRunning; + }, this.testMessage.bucketSizeMs + 5000, (errMsg: string) => `${errMsg} pollAndUploadResults never completed. Can't restartOnFailure`); + } + if (this.communicationsRunning) { + // Wait for it to finish + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.communicationsRunning; + }, this.testMessage.bucketSizeMs + 5000, (errMsg: string) => `${errMsg} pollCommunications never completed. Can't restartOnFailure`); + } + if (this.testMessage.bypassParser || Date.now() < this.testEnd) { + // If we're still less than the end after all the waits restart. + pewpewRestart = true; + } + } else { + // We're either still running, we don't have restart, we haven't run long enough, or too long. Throw away + if (!this.stopCalled && this.testMessage.restartOnFailure && Date.now() < minTimeForRetry) { + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), `pewpew did not run for at least ${MIN_RUNTIME_FOR_RETRY / 1000} seconds. Test ran for ${Math.round((Date.now() - this.startTime.getTime()) / 1000)} seconds.`]; + } + throw error; + } + } + // Keep trying while we're not running, we haven't had stop called, we are supposed to restart, we've run for a minimum amount of time and we shouldn't be done + this.iteration++; + } while (!this.pewpewRunning && !this.uploadRunning && !this.communicationsRunning && !this.stopCalled && this.testMessage.restartOnFailure && pewpewRestart); + // this is exiting gracefully + // Send finished communications message and update teststatus + this.ppaasTestStatus.status = TestStatus.Finished; + this.ppaasTestStatus.endTime = this.pewpewEnd ? this.pewpewEnd + SPLUNK_FORWARDER_EXTRA_TIME : Date.now(); // Extra time for the splunk agent to write + await Promise.all([ + this.sendTestStatus(MessageType.TestFinished), + this.writeTestStatus() // Final write + ]); + } catch (error) { + const errorMessage = `launch test error: ${error && error.message ? error.message : error}`; + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), errorMessage]; + this.log(errorMessage, LogLevel.ERROR, error); + try { + await this.internalStop(); + } catch(err) { + this.log("Could not stop the pewpew process", LogLevel.ERROR, err); + } + // Send failed communications message and update teststatus + this.ppaasTestStatus.status = TestStatus.Failed; + this.ppaasTestStatus.endTime = this.pewpewEnd ? this.pewpewEnd + SPLUNK_FORWARDER_EXTRA_TIME : Date.now(); // Extra time for the splunk agent to write + await Promise.all([ + this.sendTestStatus(MessageType.TestFailed), + this.writeTestStatus() // Final write + ]); + throw error; + } finally { + // Let us scale back in + await this.deleteTestScalingMessage(); + } + } + + protected async internalStop (): Promise { + // The exit command itself will set this back to undefined + if (this.pewpewProcess && this.pewpewRunning) { + try { + this.log(`Stopping pewpew process with SIGINT ${this.pewpewProcess.pid}`, LogLevel.INFO); + const intResult = this.pewpewProcess.kill("SIGINT"); // We be nice to them if they be nice to us. + this.log(`pewpew process SIGINT result: ${intResult}`, intResult ? LogLevel.INFO : LogLevel.WARN); + // Poll for the process to stop. + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewProcess || !this.pewpewRunning; + }, KILL_MAX_WAIT, (errMsg: string) => `${errMsg} SIGINT did not stop pewpew. We gave it a chance, now it's personal.`) + .catch((error) => this.log("SIGINT did not stop pewpew", LogLevel.ERROR, error)); + if (this.pewpewProcess && this.pewpewRunning) { + await this.internalKill(); + } else { + this.log("pewpew process stopped with SIGINT", LogLevel.INFO); + } + } catch (error) { + this.log(`Caught error stopping pewpew ${error}`, LogLevel.ERROR, error); + } + } else { + this.log("Stop called with no pewpew process", LogLevel.DEBUG); + } + } + + protected async internalKill (): Promise { + // The exit command itself will set this back to undefined + if (this.pewpewProcess && this.pewpewRunning) { + try { + this.log(`Stopping pewpew process with SIGKILL ${this.pewpewProcess.pid}`, LogLevel.WARN); + const killResult = this.pewpewProcess.kill("SIGKILL"); // We gave it a chance, now it's personal. + this.log(`pewpew process SIGKILL result: ${killResult}`, LogLevel.WARN); + // Poll for the process to stop. + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewProcess || !this.pewpewRunning; + }, KILL_MAX_WAIT, (errMsg: string) => `${errMsg} SIGINT did not stop pewpew. We gave it a chance, now it's personal.`) + .catch((error) => this.log("SIGINT did not stop pewpew", LogLevel.ERROR, error)); + } catch (error) { + this.log(`Caught error killing pewpew ${error}`, LogLevel.ERROR, error); + } + } else { + this.log("Kill called with no pewpew process", LogLevel.DEBUG); + } + } + + // Public version to tell from when it's called internally + /** + * Stops the currently running test (if running). Does nothing if it's stopped + * @param killTest {boolean} Optional: If true, a SIGKILL is immediately sent rather than a SIGINT + * @returns {Promise} + */ + public stop (killTest?: boolean): Promise { + this.stopCalled = true; + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), `Received ${killTest ? "KillTest" : "StopTest"} message from controller`]; + return killTest ? this.internalKill() : this.internalStop(); + } + + /** * Polls for the results file then uploads it every minute ** */ + protected async pollAndUploadResults (): Promise { + this.uploadRunning = true; + try { + let pewpewResultsFilename: string; + if (versionGreaterThan(this.testMessage.version, VERSION_SPECIFIC_RESULTS_FILE)) { + pewpewResultsFilename = createStatsFileName(this.testMessage.testId, this.iteration); + this.log(`Checking for results file: ${pewpewResultsFilename}`, LogLevel.DEBUG); + await poll(async (): Promise => { + const files = await fs.readdir(this.localPath); + return files && files.includes(pewpewResultsFilename); + }, this.resultsFileMaxWait, (errMsg: string) => `${errMsg} Could not find the pewpew results file: ${pewpewResultsFilename}`); + } else { + this.log(`Checking for results file in: ${this.localPath}`, LogLevel.DEBUG); + pewpewResultsFilename = await poll(async (): Promise => { + const files = await fs.readdir(this.localPath); + if (files) { + // Ignore previous runs from restartOnFailure + const jsonFiles = files.filter((file) => file.startsWith("stats-") && file.endsWith(".json") && !this.ppaasTestStatus.resultsFilename.includes(file)).sort(); + if (jsonFiles.length > 0) { + return jsonFiles[jsonFiles.length - 1]; // We need to return the full joined path + } + } + return ""; + }, this.resultsFileMaxWait, (errMsg: string) => `${errMsg} Could not find the pewpew results file in: ${this.localPath}`); + } + // If we never get results we can throw here, but otherwise should swallow it until we're done and upload the final results + this.ppaasTestStatus.resultsFilename.push(pewpewResultsFilename); // Save it in case we have restartOnFailure + this.pewpewResultsS3File = new PpaasS3File({ + filename: pewpewResultsFilename, + s3Folder: this.testMessage.s3Folder, + localDirectory: this.localPath, + publicRead: true + }); + this.log(`${this.testMessage.yamlFile} New Result File Found: ${pewpewResultsFilename}`, LogLevel.INFO, { pewpewResultsFilename }); + // Send status communications message and update teststatus + this.ppaasTestStatus.status = TestStatus.Running; + await Promise.all([ + this.sendTestStatus(), + this.writeTestStatus() + ]); + + let iteration = 0; + // Initially we want them all to upload, so zero out the last upload time + const yamlCreatedFiles: Map = new Map(); + // Keep running until the pewpew process ends, or we're more than the allowed overrage. Then upload everything regardless and exit + while (this.pewpewRunning && (this.testMessage.bypassParser || (Date.now() < this.testEnd! + ALLOW_TEST_OVERAGE))) { + const endLoop: number = Date.now() + this.testMessage.bucketSizeMs; + try { + this.log(`Polling PewPew Results. iteration: ${iteration++}`, LogLevel.DEBUG); + // Only upload the file if it's changed. Only the results file should be public + await this.pewpewResultsS3File.upload(); + if (iteration === 1) { + this.log(`${this.testMessage.yamlFile} Result URL: ${this.pewpewResultsS3File.remoteUrl}`, LogLevel.INFO, { url: this.pewpewResultsS3File.remoteUrl }); + } + await this.pewpewStdOutS3File!.upload(); + await this.pewpewStdErrS3File!.upload(); + + // Check for additional files created in the localPath + const foundFiles = await findYamlCreatedFiles(this.localPath, this.testMessage.yamlFile, this.testMessage.additionalFiles); + if (foundFiles) { + for (const foundFile of foundFiles) { + if (!yamlCreatedFiles.has(foundFile)) { + // Initially we want them all to upload, so zero out the last upload time + const foundS3File: PpaasS3File = new PpaasS3File({ + filename: foundFile, + s3Folder: this.testMessage.s3Folder, + localDirectory: this.localPath + }); + yamlCreatedFiles.set(foundFile, foundS3File); + } + } + } + // If we found additional files, upload them! + if (yamlCreatedFiles.size > 0) { + for (const s3File of yamlCreatedFiles.values()) { + await s3File.upload(); + } + } + // If we are bypassing the config parser we need to constantly push out the endtimes if we are longer than an hour. + if (this.testMessage.bypassParser && (Date.now() + TEN_MINUTES > this.ppaasTestStatus.endTime)) { + this.ppaasTestStatus.endTime = this.testEnd = Date.now() + TEN_MINUTES; + } + // Send status communications message every loop + await this.sendTestStatus(); + // Keep alive every loop + await this.refreshTestScalingMessage(); + } catch(error) { + this.log(`Polling PewPew Results Error. iteration: ${iteration}`, LogLevel.ERROR, error); + } + if (Date.now() < endLoop) { + // Sleep up to testData.bucketSizeMs seconds for next bucket, but return early if pewpew isn't running + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewRunning || Date.now() > endLoop; + }, endLoop - Date.now() + 5000) // Poll needs a duration, so 5 seconds longer than the poll should exit + .catch((error) => this.log("Poll and Upload Loop Sleep failed", LogLevel.ERROR, error)); + } + } + this.log(`Polling PewPew Results Loop ended. this.running: ${this.pewpewRunning}`, LogLevel.DEBUG); + + // Test should be done or went over + // Communications loop should stop the test + if (this.pewpewRunning) { + // Wait for the process to exit, but upload even if it doesn't stop + try { + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewProcess || !this.pewpewRunning; + }, 60000, (errMsg: string) => `${errMsg} PewPew never exited. Uploading final results anyway.`); + } catch (error) { + this.log("Pewpew never exited.", LogLevel.ERROR, error); + // Send error communications message + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), "Pewpew never exited."]; + await this.sendTestStatus(MessageType.TestError); + } + } + // Upload the final results. Only the results file should be public + const fileUploads = [ + this.pewpewResultsS3File.upload(true, true), + this.pewpewStdOutS3File!.upload(true, true), + this.pewpewStdErrS3File!.upload(true, true) + ]; + // If we found additional files, upload them! + if (yamlCreatedFiles.size > 0) { + for (const s3File of yamlCreatedFiles.values()) { + fileUploads.push(s3File.upload(true, true)); + } + } + await Promise.all(fileUploads); + if (this.pewpewRunning) { + throw new Error("pollAndUploadResults exited, but pewpew still running"); + } + } finally { + this.uploadRunning = false; + } + } + + /** * Polls the communications queue for messages from the controller ** */ + protected async pollCommunications (): Promise { + this.communicationsRunning = true; + log("Starting Communications Loop", LogLevel.INFO); + try { + let iteration = 0; + // Keep running until the pewpew process ends, or we're more than the allowed overrage. Then upload everything regardless and exit + while (this.pewpewRunning && (this.testMessage.bypassParser || (Date.now() < this.testEnd! + ALLOW_TEST_OVERAGE))) { + this.log(`Polling PewPew Communications. iteration: ${iteration++}`, LogLevel.DEBUG); + // Normally the getAnyMessageForController should take 20 seconds if there is no message in the queue. + // If there are messages (even if they're not for us) it will return immediately. We should have a sleep time if we don't get a message + let messageToHandle: PpaasS3Message | undefined; + const endLoop: number = Date.now() + COMMUCATION_NO_MESSAGE_DELAY; + try { + messageToHandle = await PpaasS3Message.getMessage(this.ppaasTestId); + } catch (error) { + this.log("Error trying to get communications message", LogLevel.ERROR, error); + await sleep(5000); + } + if (messageToHandle) { + this.log(`New message received at ${new Date()}: ${messageToHandle.messageType}`, LogLevel.DEBUG, messageToHandle.sanitizedCopy()); + // Process message and start a test + try { + switch (messageToHandle.messageType) { + case MessageType.StopTest: + this.log(`Received ${messageToHandle.messageType} for ${this.testMessage.testId}. Stopping test.`, LogLevel.INFO); + // Call the external, not internal stop + this.stop().catch(() => { /* logs automatically */ }); + this.log(`handleMessage Complete ${messageToHandle.messageType}`, LogLevel.DEBUG, messageToHandle); + break; + case MessageType.KillTest: + this.log(`Received ${messageToHandle.messageType} for ${this.testMessage.testId}. Killing test.`, LogLevel.WARN); + this.stop(true).catch(() => { /* logs automatically */ }); + this.log(`handleMessage Complete ${messageToHandle.messageType}`, LogLevel.DEBUG, messageToHandle); + break; + case MessageType.UpdateYaml: + this.log(`Received ${messageToHandle.messageType} for ${this.testMessage.testId}. Updating Yaml.`, LogLevel.INFO); + // Download the new file + await this.yamlS3File.download(); + // Check and edit the new run time + // Check the bypass parser. + if (!this.testMessage.bypassParser) { + try { + const yamlParser: YamlParser = await YamlParser.parseYamlFile(this.yamlS3File.localFilePath, this.testMessage.envVariables); + const newRuntime = yamlParser.getTestRunTimeMn(); + this.log(`${this.getYamlFile()} testRunTimeMn ${newRuntime}. Old testRunTimeMn ${this.testMessage.testRunTimeMn}.`, LogLevel.DEBUG); + if (newRuntime !== this.testMessage.testRunTimeMn && this.startTime) { + this.testMessage.testRunTimeMn = newRuntime; + this.testEnd = getEndTime(this.startTime.getTime(), this.testMessage.testRunTimeMn); + this.log(`${this.getYamlFile()} new testRunTimeMn ${newRuntime}. Updating.`, LogLevel.INFO, + { testRunTimeMn: this.testMessage.testRunTimeMn, startTime: this.startTime.getTime(), testEnd: this.testEnd }); + this.ppaasTestStatus.endTime = this.testEnd; + await this.writeTestStatus(); + } + } catch (error) { + const message: string = `Could not parse new yaml file ${this.getYamlFile()}`; + this.log(message, LogLevel.ERROR, error); + // Send error to communications queue + const errorMessage = new PpaasCommunicationsMessage({ + testId: this.testMessage.testId, + messageType: MessageType.TestError, + messageData: { message, error } + }); + errorMessage.send().catch((sendError) => this.log("Could not send error communications message to controller", LogLevel.ERROR, sendError)); + } + } + this.log(`handleMessage Complete ${messageToHandle.messageType}`, LogLevel.DEBUG, messageToHandle); + break; + default: + this.log(`The agent cannot handle messages of this type at this time. Removing from queue: ${messageToHandle.messageType}`, LogLevel.WARN, messageToHandle.sanitizedCopy()); + break; + } + await messageToHandle.deleteMessageFromS3(); + } catch (error) { + this.log("Error handling message", LogLevel.ERROR, error, messageToHandle && messageToHandle.sanitizedCopy()); + // Report to Controller + messageToHandle = undefined; + } + } else { + this.log(`No message received at ${new Date()}`, LogLevel.DEBUG); + } + if (Date.now() < endLoop) { + // PpaasS3Message.getMessage() is instant so we want to sleep until we need to recheck. + // Sleep up to COMMUCATION_NO_MESSAGE_DELAY seconds for next check, but return early if pewpew isn't running + // eslint-disable-next-line require-await + await poll(async (): Promise => { + return !this.pewpewRunning || Date.now() > endLoop; + }, endLoop - Date.now() + 5000) // Poll needs a duration, so 5 seconds longer than the poll should exit + .catch((error) => this.log("Communications Loop Sleep failed", LogLevel.ERROR, error)); + } + } + this.log(`Polling PewPew Communications Loop ended. this.running: ${this.pewpewRunning}`, LogLevel.DEBUG); + + // Test should be done or went over + // Stop the test + if (this.pewpewRunning) { + try { + // Send error communications message + const errorMessage = `Pewpew still running after estimated end time: ${this.testEnd}. Stopping Test`; + this.log(errorMessage, LogLevel.WARN, { testEnd: this.testEnd }); + this.ppaasTestStatus.errors = [...(this.ppaasTestStatus.errors || []), errorMessage]; + await Promise.all([ + this.sendTestStatus(MessageType.TestError), + this.writeTestStatus(), + this.internalStop() + ]); + } catch(err) { + this.log("Could not stop the pewpew process", LogLevel.ERROR, err); + } + // Keep trying to check and kill the pewpew process? + while (this.pewpewProcess && this.pewpewRunning) { + try { + // Send error communications message + const errorMessage = `Pewpew still running after estimated end time: ${this.testEnd}. Stopping Test`; + this.log(errorMessage, LogLevel.ERROR, { testEnd: this.testEnd }); + await this.internalStop(); + if (this.pewpewRunning) { + await sleep(10000); + } + } catch(err) { + this.log("Could not stop the pewpew process", LogLevel.ERROR, err); + } + } + } + } finally { + this.communicationsRunning = false; + } + } +} diff --git a/agent/src/server.ts b/agent/src/server.ts new file mode 100644 index 00000000..feb69adb --- /dev/null +++ b/agent/src/server.ts @@ -0,0 +1,87 @@ +import { LogLevel, log, logger } from "@fs/ppaas-common"; +import { Address } from "cluster"; +import { Application } from "express"; +import { PewPewTest } from "./pewpewtest"; +import { Server } from "http"; +import express from "express"; +import { init as initHealthcheck } from "./healthcheck"; +import { init as initTests } from "./tests"; + +// We have to set this before we make any log calls +logger.config.LogFileName = "ppaas-agent"; + +const PORT: number = parseInt(process.env.PORT || "0", 10) || 8080; +const TIMEOUT: number = parseInt(process.env.TIMEOUT || "0", 10) || 30000; + +let server: Server; + +export interface ServerConfig { + testToRun: PewPewTest | undefined; +} + +export const config: ServerConfig = { + testToRun: undefined +}; + +export function start (): Application { + const app: Application = express(); + + server = app.listen(PORT, () => { + app.use("/healthcheck", initHealthcheck()); + app.use("/tests", initTests()); + app.get("/", (_req, res) => { + try { + if (config.testToRun) { + const testId = config.testToRun.getTestId(); + const yamlFile = config.testToRun.getTestId(); + const resultsUrl = config.testToRun.getResultsFileS3(); + res.status(200).json({ message: "Test Currently Running", testId, yamlFile, resultsUrl }); + } else { + res.status(200).json({ message: "No test currently running" }); + } + } catch (error) { + res.status(500).json({ message: "Error stopping test", testId: config.testToRun && config.testToRun.getTestId(), error }); + } + }); + app.get("/stop", async (req, res) => { + try { + if (config.testToRun) { + const requestedTestId = req.query.testId; + const testId = config.testToRun.getTestId(); + if (requestedTestId !== testId) { + res.status(400).json({ message: "Invalid testId passed to stop. Please pass the correct query parameter 'testId'.", testId }); + return; + } + const yamlFile = config.testToRun.getYamlFile(); + const resultsUrl = config.testToRun.getResultsFileS3(); + await config.testToRun.stop(); + res.status(200).json({ message: "Stop Test successfully called", testId, yamlFile, resultsUrl }); + } else { + res.status(400).json({ message: "No test currently running" }); + } + } catch (error) { + res.status(500).json({ message: "Error stopping test", testId: config.testToRun && config.testToRun.getTestId(), error }); + } + }); + let address: string; + if (typeof server.address() === "string") { + address = server.address() as string; + } else { + const addr = server.address() as unknown as Address; + address = addr.address + ":" + addr.port; + } + log(`PewPew Agent using Node.js + TypeScript listening at http://${address}}`); + }); + server.setTimeout(TIMEOUT); + return app; +} + +export function stop (): Promise { + log("server quitting"); + return new Promise((resolve) => server.close((error) => { + if (error && error.message) { + log("error stopping node server", LogLevel.ERROR, error); + } + resolve(); // Always swallow the error, don't throw it + })); +} diff --git a/agent/src/tests.ts b/agent/src/tests.ts new file mode 100644 index 00000000..e3eabe45 --- /dev/null +++ b/agent/src/tests.ts @@ -0,0 +1,162 @@ +import { + LogLevel, + PpaasTestId, + PpaasTestMessage, + TestMessage, + log, + logger, + s3, + util +} from "@fs/ppaas-common"; +import { NextFunction, Request, Response, Router } from "express"; +import ExpiryMap from "expiry-map"; +import { PewPewTest } from "./pewpewtest"; +import { config as healthcheckConfig } from "./healthcheck"; +import { join as pathJoin } from "path"; + +// We have to set this before we make any log calls +logger.config.LogFileName = "ppaas-agent"; + +const UNIT_TEST_FOLDER = process.env.UNIT_TEST_FOLDER || "test"; +const yamlFile = "basicwithenv.yaml"; +const version = "latest"; +const PEWPEW_PATH = process.env.PEWPEW_PATH || pathJoin(UNIT_TEST_FOLDER, "pewpew"); +const buildTestContents = ` +vars: + rampTime: 10s + loadTime: 10s + serviceUrlAgent: \${SERVICE_URL_AGENT} +load_pattern: + - linear: + from: 1% + to: 100% + over: \${rampTime} + - linear: + from: 100% + to: 100% + over: \${loadTime} +config: + client: + headers: + TestTime: '\${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://\${serviceUrlAgent}/healthcheck + peak_load: 30hpm +`; + +/** key is an id/timestamp, result is either boolean (finished/not finished) or error */ +const buildTestMap = new ExpiryMap(600_000); // 10 minutes + +export async function buildTest ({ + unitTest, + ppaasTestId = PpaasTestId.makeTestId(yamlFile) +}: { unitTest?: boolean, ppaasTestId?: PpaasTestId } = {}) { + // Make sure we can run a basic test + try { + const { testId, s3Folder } = ppaasTestId; + await Promise.all([ + s3.uploadFileContents({ + contents: buildTestContents, + filename: yamlFile, + s3Folder, + publicRead: false, + contentType: "text/x-yaml" + }), + s3.uploadFile({ + filepath: PEWPEW_PATH, + s3Folder: `pewpew/${version}`, + publicRead: false, + contentType: "application/octet-stream" + }) + ]); + log(`${process.env.FS_SYSTEM_NAME} environment basic startup test starting`, LogLevel.WARN, { testId, s3Folder, yamlFile }); + const startTime = Date.now(); + // Upload files + const testMessage: TestMessage = { + testId, + s3Folder, + yamlFile, + // additionalFiles: additionalFileNames.length > 0 ? additionalFileNames : undefined, + testRunTimeMn: 2, + version, + envVariables: { SERVICE_URL_AGENT: "127.0.0.1:8080" }, + restartOnFailure: false, + userId: "buildTest" + }; + + const pewPewTest: PewPewTest = new PewPewTest(new PpaasTestMessage(testMessage)); + await pewPewTest.launch(); + log(`${process.env.FS_SYSTEM_NAME} environment basic startup test succeeded!`, LogLevel.WARN, { duration: Date.now() - startTime }); + } catch (error) { + healthcheckConfig.failHealthCheck = true; + healthcheckConfig.failHealthCheckMessage = `environment basic startup test failed: ${error}`; + // Log at both levels so we catch it even when just searching for errors + const errorMessage: string = `${process.env.FS_SYSTEM_NAME} environment basic startup test failed`; + log(errorMessage, LogLevel.ERROR, error); + log(errorMessage, LogLevel.FATAL, error); + // Don't throw, or this will keep restarting, just fail the healthcheck + if (unitTest) { + throw error; + } + do { + await util.sleep(60000); + log(errorMessage, LogLevel.ERROR, error); + log(errorMessage, LogLevel.FATAL, error); + } while (healthcheckConfig.failHealthCheck === true); + } +} + +export function init (): Router { + const router: Router = Router(); + // middleware that is specific to this router + router.use((req: Request, res: Response, next: NextFunction) => { + log(`originalUrl:${req.originalUrl}`, LogLevel.DEBUG); + res.type("application/json"); + next(); + }); + // define the home page route + router.get("/", (_req: Request, res: Response) => { + res.status(404).send(); + }); + router.get("/build", (req: Request, res: Response) => { + // endpoint no jobId query param starts test returns a job id + // endpoint with jobId returns the status of a job id + const { jobId } = req.query; + if (jobId === undefined) { + try { + const ppaasTestId = PpaasTestId.makeTestId(yamlFile); + const newJobId = ppaasTestId.testId; + buildTestMap.set(newJobId, false); + buildTest({ unitTest: true, ppaasTestId }) + .then(() => buildTestMap.set(newJobId, true)) + .catch((error: unknown) => buildTestMap.set(newJobId, error)); + res.status(200).json({ jobId: newJobId }); + } catch (error: unknown) { + res.status(500).json({ build: false, error }); + } + } else if (typeof jobId === "string") { + const result: boolean | unknown | undefined = buildTestMap.get(jobId); + if (result !== undefined) { + const status = result === true + ? 200 // Success + : result === false + ? 202 // Pending + : 500; // error + res.status(status).json({ build: result }); + } else { + res.status(404).send(); + } + } else { + res.status(400).json({ message: "Invalid jobId" }); + } + }); + + return router; +} diff --git a/agent/src/util/util.ts b/agent/src/util/util.ts new file mode 100644 index 00000000..afdf8e96 --- /dev/null +++ b/agent/src/util/util.ts @@ -0,0 +1,14 @@ +import { util } from "@fs/ppaas-common"; + +// It's application-system-service-last-3-octets +const PREFIX: string = `${util.APPLICATION_NAME.toLowerCase()}-${util.SYSTEM_NAME.toLowerCase()}-app-`; + +export function getHostname (): string { + const ipAddress = util.getLocalIpAddress(); + const split: string[] = ipAddress.split("."); + if (split.length !== 4) { + throw new Error("Could not get last 3 octets of IP address from: " + ipAddress); + } + const hostname = PREFIX + split.slice(1).join("-"); + return hostname; +} diff --git a/agent/test/healthcheck.spec.ts b/agent/test/healthcheck.spec.ts new file mode 100644 index 00000000..d19fea41 --- /dev/null +++ b/agent/test/healthcheck.spec.ts @@ -0,0 +1,35 @@ +import { S3_ALLOWED_LAST_ACCESS_MS, SQS_ALLOWED_LAST_ACCESS_MS, accessS3Pass, accessSqsPass } from "../src/healthcheck"; +import { expect } from "chai"; +import { logger } from "@fs/ppaas-common"; + +logger.config.LogFileName = "ppaas-agent"; + +describe("Healthcheck", () => { + describe("S3 Healthcheck should check last s3 access", () => { + it("Should pass if we have a recent access", (done: Mocha.Done) => { + expect(accessS3Pass(new Date(Date.now() - S3_ALLOWED_LAST_ACCESS_MS + 10000))).to.equal(true); + done(); + }); + }); + + describe("S3 Healthcheck should check last s3 access", () => { + it("Should fail if we don't have a recent access", (done: Mocha.Done) => { + expect(accessS3Pass(new Date(Date.now() - S3_ALLOWED_LAST_ACCESS_MS - 10000))).to.equal(false); + done(); + }); + }); + + describe("SQS Healthcheck should check last sqs access", () => { + it("Should pass if we have a recent access", (done: Mocha.Done) => { + expect(accessSqsPass(new Date(Date.now() - SQS_ALLOWED_LAST_ACCESS_MS + 10000))).to.equal(true); + done(); + }); + }); + + describe("SQS Healthcheck should check last sqs access", () => { + it("Should fail if we don't have a recent access", (done: Mocha.Done) => { + expect(accessSqsPass(new Date(Date.now() - SQS_ALLOWED_LAST_ACCESS_MS - 10000))).to.equal(false); + done(); + }); + }); +}); diff --git a/agent/test/pewpew b/agent/test/pewpew new file mode 100755 index 00000000..cf50ea86 Binary files /dev/null and b/agent/test/pewpew differ diff --git a/agent/test/pewpewtest.spec.ts b/agent/test/pewpewtest.spec.ts new file mode 100644 index 00000000..17d7cf74 --- /dev/null +++ b/agent/test/pewpewtest.spec.ts @@ -0,0 +1,332 @@ +import { + LogLevel, + PpaasTestId, + PpaasTestStatus, + TestStatus, + TestStatusMessage, + log, + logger +} from "@fs/ppaas-common"; +import { + copyTestStatus, + findYamlCreatedFiles, + versionGreaterThan +} from "../src/pewpewtest"; +import { expect } from "chai"; +import { readdir } from "fs/promises"; + +export const UNIT_TEST_FILENAME: string = process.env.UNIT_TEST_FILENAME || "s3test.txt"; +export const UNIT_TEST_FILEDIR: string = process.env.UNIT_TEST_FILEDIR || "test/"; +const CREATE_TEST_FILENAME: string = process.env.CREATE_TEST_FILENAME || "createtest.yaml"; + +logger.config.LogFileName = "ppaas-agent"; + +describe("PewPewTest", () => { + describe("findYamlCreatedFiles", () => { + let localFiles: string[]; + + before (async () => { + localFiles = await readdir(UNIT_TEST_FILEDIR); + log(`localFiles = ${JSON.stringify(localFiles)}`, LogLevel.DEBUG); + const unitTestFound = localFiles.indexOf(UNIT_TEST_FILENAME); + if (unitTestFound >= 0) { + localFiles.splice(unitTestFound, 1); + } + const pewpewFound = localFiles.indexOf("pewpew"); + if (pewpewFound >= 0) { + localFiles.splice(pewpewFound, 1); + } + log(`localFiles removed = ${JSON.stringify(localFiles)}`, LogLevel.DEBUG); + }); + + it("Find Yaml should find nothing when everything passed", (done: Mocha.Done) => { + findYamlCreatedFiles(UNIT_TEST_FILEDIR, UNIT_TEST_FILENAME, localFiles) + .then((foundFiles: string[] | undefined) => { + log(`empty foundFiles = ${JSON.stringify(foundFiles)}`, LogLevel.DEBUG); + expect(foundFiles).to.equal(undefined); + done(); + }) + .catch((error) => { + log(`empty foundFiles error = ${error}`, LogLevel.ERROR, error); + done(error); + }); + }); + + it("Find Yaml should find one file", (done: Mocha.Done) => { + const slicedArray: string[] = localFiles.slice(1); + findYamlCreatedFiles(UNIT_TEST_FILEDIR, UNIT_TEST_FILENAME, slicedArray) + .then((foundFiles: string[] | undefined) => { + log(`non-empty foundFiles = ${JSON.stringify(foundFiles)}`, LogLevel.DEBUG); + expect(foundFiles).to.not.equal(undefined); + expect(foundFiles!.length).to.equal(1); + done(); + }) + .catch((error) => { + log(`empty foundFiles error = ${error}`, LogLevel.ERROR, error); + done(error); + }); + }); + + it("Find Yaml should find all the files", (done: Mocha.Done) => { + const emptryArray: string[] = []; + findYamlCreatedFiles(UNIT_TEST_FILEDIR, UNIT_TEST_FILENAME, emptryArray) + .then((foundFiles: string[] | undefined) => { + log(`non-empty foundFiles = ${JSON.stringify(foundFiles)}`, LogLevel.DEBUG); + expect(foundFiles, "foundFiles").to.not.equal(undefined); + expect(foundFiles!.length, `foundFiles.length: ${JSON.stringify(foundFiles)}, localFiles.length: ${JSON.stringify(localFiles)}`).to.equal(localFiles.length); + done(); + }) + .catch((error) => { + log(`empty foundFiles error = ${error}`, LogLevel.ERROR, error); + done(error); + }); + }); + }); + + describe("versionGreaterThan", () => { + it("latest is always greater", (done: Mocha.Done) => { + expect(versionGreaterThan("latest", "")).to.equal(true); + done(); + }); + + it("latest is always greater than latest", (done: Mocha.Done) => { + expect(versionGreaterThan("latest", "latest")).to.equal(true); + done(); + }); + + it("greater than latest is false", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.5", "latest")).to.equal(false); + done(); + }); + + it("beta is not greater than non-beta same version", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.4-beta", "0.5.4")).to.equal(false); + done(); + }); + + it("beta is greater than previous version", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.5-beta", "0.5.4")).to.equal(true); + done(); + }); + + it("Patch version is greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.5", "0.5.4")).to.equal(true); + done(); + }); + + it("Patch version is not greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.3", "0.5.4")).to.equal(false); + done(); + }); + + it("Minor version is greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("0.6.1", "0.5.4")).to.equal(true); + done(); + }); + + it("Minor version is not greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.3", "0.6.0")).to.equal(false); + done(); + }); + + it("Major version is greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("1.0.0", "0.5.4")).to.equal(true); + done(); + }); + + it("Major version is not greater than", (done: Mocha.Done) => { + expect(versionGreaterThan("0.5.3", "1.0.0")).to.equal(false); + done(); + }); + + it("compare version cannot be a beta", (done: Mocha.Done) => { + try { + versionGreaterThan("0.5.5", "0.5.4-beta"); + done(new Error("Should have failed.")); + } catch (error) { + expect(`${error}`).include("compareVersion cannot be a beta"); + done(); + } + }); + + it("compare version cannot be a beta", (done: Mocha.Done) => { + try { + expect(versionGreaterThan("0.5.10-preview1", "0.5.5")).to.equal(true); + done(new Error("Should have failed.")); + } catch (error) { + done(); + } + }); + }); + + describe("copyTestStatus", () => { + const ppaasTestId: PpaasTestId = PpaasTestId.makeTestId(CREATE_TEST_FILENAME); + const now = Date.now(); + const basicTestStatusMessage: TestStatusMessage = { + startTime: now + 1, + endTime: now + 2, + resultsFilename: ["test1"], + status: TestStatus.Created + }; + const fullTestStatusMessage: Required = { + startTime: now + 3, + endTime: now + 4, + resultsFilename: ["bogus"], + status: TestStatus.Running, + instanceId: "instance1", + hostname: "host1", + ipAddress: "ipAddress1", + errors: ["error1"], + version: "version1", + queueName: "queue1", + userId: "user1" + }; + const fullTestStatusMessageChanged: Required = { + startTime: now + 5, + endTime: now + 6, + resultsFilename: ["bogus1", "bogus2"], + status: TestStatus.Finished, + instanceId: "instance2", + hostname: "host2", + ipAddress: "ipAddress2", + errors: ["error2", "error3"], + version: "version2", + queueName: "queue2", + userId: "user2" + }; + const extendedTestStatusMessage: TestStatusMessage = { + startTime: now + 1, + endTime: now + 2, + resultsFilename: ["test2"], + status: TestStatus.Failed, + version:"version3", + userId: "user3" + }; + + it("current basic, undefined in s3 should keep the same", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, basicTestStatusMessage); + const expectedTestStatusMessage = basicTestStatusMessage; + copyTestStatus(ppaasTestStatus, undefined, basicTestStatusMessage); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + + it("current full, undefined in s3 should keep the same", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, fullTestStatusMessage); + const expectedTestStatusMessage = fullTestStatusMessage; + copyTestStatus(ppaasTestStatus, undefined, fullTestStatusMessage); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + + it("current full, basic in s3 should keep the same", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, fullTestStatusMessage); + const expectedTestStatusMessage = fullTestStatusMessage; + copyTestStatus(ppaasTestStatus, basicTestStatusMessage, fullTestStatusMessage); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + + it("current basic, full in s3 should use full values where basic is missing", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, basicTestStatusMessage); + const expectedTestStatusMessage = { ...fullTestStatusMessage, ...basicTestStatusMessage }; + copyTestStatus(ppaasTestStatus, fullTestStatusMessage, basicTestStatusMessage); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + + it("current basic, extended in s3 should use extended values where basic is missing", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, basicTestStatusMessage); + const expectedTestStatusMessage = { ...extendedTestStatusMessage, ...basicTestStatusMessage }; + copyTestStatus(ppaasTestStatus, extendedTestStatusMessage, basicTestStatusMessage); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + + it("current full2, full in s3 should use full2 values", (done: Mocha.Done) => { + try { + const ppaasTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, fullTestStatusMessageChanged); + const expectedTestStatusMessage = fullTestStatusMessageChanged; + copyTestStatus(ppaasTestStatus, fullTestStatusMessage, fullTestStatusMessageChanged); + expect(ppaasTestStatus.startTime, "startTime").to.equal(expectedTestStatusMessage.startTime); + expect(ppaasTestStatus.endTime, "endTime").to.equal(expectedTestStatusMessage.endTime); + expect(JSON.stringify(ppaasTestStatus.resultsFilename), "resultsFilename").to.equal(JSON.stringify(expectedTestStatusMessage.resultsFilename)); + expect(ppaasTestStatus.status, "status").to.equal(expectedTestStatusMessage.status); + expect(ppaasTestStatus.instanceId, "instanceId").to.equal(expectedTestStatusMessage.instanceId); + expect(ppaasTestStatus.hostname, "hostname").to.equal(expectedTestStatusMessage.hostname); + expect(JSON.stringify(ppaasTestStatus.errors), "resultsFierrorslename").to.equal(JSON.stringify(expectedTestStatusMessage.errors)); + expect(ppaasTestStatus.version, "version").to.equal(expectedTestStatusMessage.version); + expect(ppaasTestStatus.queueName, "queueName").to.equal(expectedTestStatusMessage.queueName); + expect(ppaasTestStatus.userId, "userId").to.equal(expectedTestStatusMessage.userId); + done(); + } catch (error) { + done(error); + } + }); + }); +}); diff --git a/agent/test/s3test.txt b/agent/test/s3test.txt new file mode 100644 index 00000000..1a260539 --- /dev/null +++ b/agent/test/s3test.txt @@ -0,0 +1 @@ +{"message":"Test file to upload."} \ No newline at end of file diff --git a/agent/test/util.spec.ts b/agent/test/util.spec.ts new file mode 100644 index 00000000..df0a2620 --- /dev/null +++ b/agent/test/util.spec.ts @@ -0,0 +1,32 @@ +import { LogLevel, log, logger, util } from "@fs/ppaas-common"; +import { expect } from "chai"; +import { getHostname } from "../src/util/util"; + +logger.config.LogFileName = "ppaas-agent"; + +describe("Util", () => { + let ipAddress: string | undefined; + + describe("getLocalIpAddress", () => { + it("getLocalIpAddress should retrieve an ipaddress", (done: Mocha.Done) => { + ipAddress = util.getLocalIpAddress(); + log("ipAddress = " + ipAddress, LogLevel.DEBUG); + expect(ipAddress).to.not.equal(undefined); + expect(/\d+\.\d+\.\d+\.\d+/.test(ipAddress), ipAddress).to.equal(true); + done(); + }); + }); + + describe("getHostName", () => { + it("getHostName should create the hostname from the Ip", (done: Mocha.Done) => { + if (ipAddress) { + const hostname = getHostname(); + log("hostname = " + hostname, LogLevel.DEBUG); + expect(/\w+-\w+-app-\d+-\d+-\d+/.test(hostname), hostname).to.equal(true); + done(); + } else { + done(new Error("ipAddress was not set")); + } + }); + }); +}); diff --git a/agent/tsconfig.json b/agent/tsconfig.json new file mode 100644 index 00000000..3a80c676 --- /dev/null +++ b/agent/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "plugins": [{ + "name": "tslint-language-service" + }], + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "isolatedModules": true, + "jsx": "react", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitUseStrict": false, + "removeComments": false, + "noLib": false, + "preserveConstEnums": true, + "suppressImplicitAnyIndexErrors": false, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "sourceMap": true, + "outDir": "dist", + "baseUrl": "src", + "lib": ["es2020", "esnext.asynciterable", "dom", "dom.iterable"] + }, + "exclude": ["node_modules"], + "include": ["src/**/*", "test/**/*", "acceptance/**/*", "createtest/**/*"], + "compileOnSave": true, + "buildOnSave": false +} + \ No newline at end of file diff --git a/agent/tsconfig.ref.json b/agent/tsconfig.ref.json new file mode 100644 index 00000000..a4d1b49f --- /dev/null +++ b/agent/tsconfig.ref.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + }, +} \ No newline at end of file diff --git a/common/.env.test b/common/.env.test new file mode 100755 index 00000000..61033616 --- /dev/null +++ b/common/.env.test @@ -0,0 +1,15 @@ +# NODE_ENV=test ignores .env and .env.local +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="unit-test-bucket" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://unit-test-bucket.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="unittests/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewcontroller" + +CONTROLLER_ENV="unittests" +AGENT_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-communication" + +ENV_KEY=".env.test" diff --git a/common/.github/workflows/pr.yml b/common/.github/workflows/pr.yml new file mode 100644 index 00000000..846ffacc --- /dev/null +++ b/common/.github/workflows/pr.yml @@ -0,0 +1,34 @@ +on: + pull_request: + +name: Pull Request Javascript +jobs: + test-release: + name: Build project + strategy: + matrix: + node-version: [16.x, 18.x, 20.x] + runs-on: ubuntu-latest + # env: + # USE_XVFB: true + + steps: + - uses: actions/checkout@v2 + + - name: Add Node.js toolchain ${{ matrix.node-version }} + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Setup Artifactory + env: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo -e "machine github.com\n login $CI_USER_TOKEN" > ~/.netrc + echo "//familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/:_authToken=${NPM_TOKEN}" >> ~/.npmrc + echo "@fs:registry=https://familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/" >> ~/.npmrc + - name: Install NPM Dependencies + run: npm ci + - name: Run Tests + run: NODE_ENV=test npm test diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 00000000..404be21f --- /dev/null +++ b/common/.gitignore @@ -0,0 +1,28 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +setaws.sh + +# development +/dist +/.nyc_output +/coverage + +# misc +.DS_Store +.env +.env.local* +.env.development.local* +.env.test.local* +.env.production.local* +.idea +.vscode/ + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +agent.log* +app-pp*.json* diff --git a/common/.sample-env b/common/.sample-env new file mode 100755 index 00000000..d92b9fe5 --- /dev/null +++ b/common/.sample-env @@ -0,0 +1,17 @@ +# https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables +# Next.js will load these automatically. We use dotenv-flow to load them for mocha +# Copy this file to .env.local and modify these to your services + +# AWS_PROFILE=default +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="my-test-service" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://my-test-service.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="pewpewcontroller-unittests-s3/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewcontroller" + +CONTROLLER_ENV="unittests" +AGENT_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewcontroller-unittests-sqs-communication" diff --git a/common/README.md b/common/README.md new file mode 100644 index 00000000..4128ad2a --- /dev/null +++ b/common/README.md @@ -0,0 +1,69 @@ +# ppaas-common +Common Code for the PewPew as a Service ([ppaas-agent](https://github.com/FamilySearch/pewpew/agent) and [ppaas-controller](https://github.com/FamilySearch/pewpew/controller)) + +## Purpose +This allows us to run load tests using [pewpew](https://github.com/FamilySearch/pewpew) via a AWS without having to manually create an ec2 instance. By putting the test files in s3 and putting a message on an SQS queue, an EC2 instance will be spun up to run the test, then shutdown when complete. + +## Installation +```sh +npm install https://github.com/FamilySearch/pewpew/common --save +yarn add https://github.com/FamilySearch/pewpew/common +``` + +## Usage +### Javascript +```javascript +var ppaas-common = require("ppaas-common"); +ppaas-common.log.log("Log to console", ppaas-common.log.LogLevel.ERROR); +``` + +### TypeScript +```typescript +import { log as logger } from "ppaas-common"; +logger.log("Log to console", logger.LogLevel.ERROR); +``` + +## Environment Config +For your full deployment you should have environment variables injected into CloudFormation to set up the S3 bucket and SQS queues. For local development, copy the `.sample-env` file to `.env.local` (or run `node setup.js`). Then modify the .env.local file to point to your S3 bucket and your SQS queues. You can also override the default AWS profile for your local testing via the `AWS_PROFILE` variable if you are not using `default`. + +## Build +```bash +$ npm i && npm run build +``` + +## Test +```bash +# This will automatically get called when you try to commit +$ npm test +``` + +## npm run commands +```bash +# start server +$ npm start + +# build the TypeScript code (output dir: build/) +$ npm run build + +# test +$ npm test + +# Run the integration tests that access AWS +# You must set your aws credentials +# For the EC2 instanceId test you must create the file "/var/lib/cloud/data/instance-id" +# on your local box with only an instanceId in the file. It should be i-. Ex. i-localdevelopment +$ npm run integration + +# Run the code coverage tests (test + integration) +# You must set your aws credentials +# For the EC2 instanceId test you must create the file "/var/lib/cloud/data/instance-id" +# on your local box with only an instanceId in the file. It should be i-. Ex. i-localdevelopment +$ npm run coverage + +# style check TypeScript +$ npm run lint + +# delete the build dir +$ npm run clean +``` + diff --git a/common/integration/ec2.spec.ts b/common/integration/ec2.spec.ts new file mode 100644 index 00000000..82b2e93f --- /dev/null +++ b/common/integration/ec2.spec.ts @@ -0,0 +1,22 @@ +import { LogLevel, ec2, log } from "../src/index"; +import { expect } from "chai"; + +describe("EC2 Integration", () => { + before (() => { + ec2.init(); + }); + + it("getInstanceId should get instanceId", (done: Mocha.Done) => { + ec2.getInstanceId().then((result: string) => { + log("getInstanceId", LogLevel.INFO, { result }); + expect(result, "result").to.not.equal(undefined); + expect(ec2.INSTANCE_ID_REGEX.test(result), `${ec2.INSTANCE_ID_REGEX}.test("${result}")`).to.equal(true); + done(); + }).catch ((error) => { + log("getInstanceId error", LogLevel.WARN, error); + log(`Please create the ${ec2.INSTANCE_ID_FILE} on your local computer to run integration tests`, LogLevel.WARN, error); + log(`${ec2.INSTANCE_ID_FILE} should have a single line with an instanceId like 'i-' with only letters and numbers`, LogLevel.WARN, error); + done(error); + }); + }); +}); diff --git a/common/integration/ppaasteststatus.spec.ts b/common/integration/ppaasteststatus.spec.ts new file mode 100644 index 00000000..7450ad08 --- /dev/null +++ b/common/integration/ppaasteststatus.spec.ts @@ -0,0 +1,174 @@ +import { + LogLevel, + PpaasTestId, + PpaasTestStatus, + TestStatus, + TestStatusMessage, + log, + s3 +} from "../src/index"; +import { expect } from "chai"; +import { getKey } from "../src/ppaasteststatus"; + +describe("PpaasTestStatus", () => { + let ppaasTestId: PpaasTestId; + // Required<> so that any new properties will fail until we add them to our test. + let testStatus: Required; + let ppaasTestWriteStatus: PpaasTestStatus; + let ppaasTestReadStatus: PpaasTestStatus | undefined; + let fileWritten: boolean = false; + let fileRead: boolean = false; + + before(() => { + // This test was failing until we reset everything. I don't know why and it bothers me. + s3.config.s3Client = undefined as any; + s3.init(); + ppaasTestId = PpaasTestId.makeTestId("UnitTest"); + testStatus = { + instanceId: "i-testinstance", + hostname: "localhost", + ipAddress: "127.0.0.1", + startTime: Date.now() - 60000, + endTime: Date.now(), + resultsFilename: [ppaasTestId.testId + ".json"], + status: TestStatus.Running, + errors: ["Test Error"], + version: "latest", + queueName: "unittest", + userId: "unittestuser" + }; + ppaasTestWriteStatus = new PpaasTestStatus(ppaasTestId, testStatus); + }); + + after(async () => { + const key = getKey(ppaasTestId); + try { + await s3.deleteObject(key); + } catch (error) { + log(`Could not delete ${key} from s3`, LogLevel.WARN, error); + } + }); + + describe("Send Status to S3", () => { + it("PpaasTestStatus.writeStatus() should succeed", (done: Mocha.Done) => { + log("creating ppaasTestStatus", LogLevel.DEBUG); + try { + ppaasTestWriteStatus = new PpaasTestStatus(ppaasTestId, testStatus); + log("ppaasTestStatus", LogLevel.DEBUG, ppaasTestWriteStatus.sanitizedCopy()); + ppaasTestWriteStatus.writeStatus().then((url: string | undefined) => { + log("PpaasTestStatus.send() result: " + url, LogLevel.DEBUG); + expect(url).to.not.equal(undefined); + fileWritten = true; + done(); + }).catch((error) => { + log("PpaasTestStatus.send() error", LogLevel.ERROR, error); + done(error); + }); + } catch (error) { + log("Send To Communiations S3 Queue Error", LogLevel.ERROR, error); + done(error); + } + }); + }); + + describe("Read Status from S3", () => { + let lastModifiedRemote: Date | undefined; + let contents: string; + + before (async () => { + expect(ppaasTestId).to.not.equal(undefined); + if (!fileWritten) { + await ppaasTestWriteStatus.writeStatus(); + fileWritten = true; + } + contents = JSON.stringify(ppaasTestWriteStatus.getTestStatusMessage()); + log("contents", LogLevel.DEBUG, { contents, testStatus }); + // Read it to get the accurate lastModifiedRemote + await ppaasTestWriteStatus.readStatus(true); + lastModifiedRemote = ppaasTestWriteStatus?.getLastModifiedRemote(); + log("read contents", LogLevel.DEBUG, { contents: JSON.stringify(ppaasTestWriteStatus.getTestStatusMessage()), testStatus }); + }); + + it("PpaasTestStatus.getStatus exists", (done: Mocha.Done) => { + PpaasTestStatus.getStatus(ppaasTestId).then((result: PpaasTestStatus | undefined) => { + log("PpaasTestStatus.getStatus result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result?.getLastModifiedRemote().getTime()).to.equal(lastModifiedRemote?.getTime()); + ppaasTestReadStatus = result; + fileRead = true; + done(); + }).catch((error) => { + log("PpaasTestStatus.getStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.getAllStatus exists", (done: Mocha.Done) => { + PpaasTestStatus.getAllStatus("unittest") + .then((result: Promise[] | undefined) => { + log("PpaasTestStatus.getAllStatus result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.length).to.be.greaterThan(0); + Promise.all(result!).then((statuses: (PpaasTestStatus | undefined)[]) => { + let foundStatus: PpaasTestStatus | undefined; + for (const status of statuses) { + expect(status).to.not.equal(undefined); + if (status?.getTestId() === ppaasTestId.testId) { + expect(status.getLastModifiedRemote().getTime()).to.equal(lastModifiedRemote?.getTime()); + foundStatus = status; + ppaasTestReadStatus = status; + fileRead = true; + break; + } + } + expect(foundStatus, "found ppaasTestStatus").to.not.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + }); + + describe("getTestStatusMessage", () => { + before (async () => { + expect(ppaasTestId).to.not.equal(undefined); + if (!fileWritten) { + await ppaasTestWriteStatus.writeStatus(); + fileWritten = true; + log("write contents", LogLevel.DEBUG, { contents: JSON.stringify(ppaasTestWriteStatus.getTestStatusMessage()), testStatus }); + } + if (ppaasTestReadStatus === undefined) { + ppaasTestReadStatus = new PpaasTestStatus(ppaasTestId, { + startTime: Date.now(), + endTime: Date.now(), + resultsFilename: [], + status: TestStatus.Unknown + }); + } + if (!fileRead) { + ppaasTestReadStatus.readStatus(true); + fileRead = true; + log("read contents", LogLevel.DEBUG, { contents: JSON.stringify(ppaasTestReadStatus.getTestStatusMessage()), testStatus }); + } + }); + + it("getTestStatusMessage should have all properties of a TestStatusMessage", (done: Mocha.Done) => { + if (ppaasTestReadStatus === undefined) { + done("ppaasTestReadStatus was undefined"); + return; + } + const actualTestMessage = ppaasTestReadStatus.getTestStatusMessage(); + log("getTestStatusMessage", LogLevel.DEBUG, { testStatus, actualTestMessage }); + expect(Object.keys(actualTestMessage).length, `Actual Keys: ${Object.keys(actualTestMessage).toString()}\nExpected Keys: ${Object.keys(testStatus).toString()}\nMessage keys length`).to.equal(Object.keys(testStatus).length); + for (const key in actualTestMessage) { + expect(JSON.stringify(actualTestMessage[key as keyof TestStatusMessage]), key).to.equal(JSON.stringify(testStatus[key as keyof TestStatusMessage])); + } + done(); + }); + }); +}); diff --git a/common/integration/s3.spec.ts b/common/integration/s3.spec.ts new file mode 100644 index 00000000..c39f072e --- /dev/null +++ b/common/integration/s3.spec.ts @@ -0,0 +1,1074 @@ +import * as path from "path"; +import { + ADDITIONAL_TAGS_ON_ALL, + copyFile, + copyObject, + deleteObject, + getFile, + getFileContents, + getObject, + getObjectTagging, + getTags, + init as initS3, + listFiles, + listObjects, + putObjectTagging, + putTags, + config as s3Config, + setAccessCallback, + uploadFile, + uploadFileContents, + uploadObject +} from "../src/util/s3"; +import { + CompleteMultipartUploadCommandOutput, + CopyObjectCommandOutput, + GetObjectCommandOutput, + GetObjectTaggingCommandOutput, + ListObjectsV2CommandOutput, + PutObjectTaggingCommandOutput, + _Object as S3Object, + Tag as S3Tag +} from "@aws-sdk/client-s3"; +import { LogLevel, S3File, log, util } from "../src/index"; +import { Stats, createReadStream } from "fs"; +import { expect } from "chai"; +import fs from "fs/promises"; +import { poll } from "../src/util/util"; +import { promisify } from "util"; +import { gunzip as zlibGunzip } from "zlib"; + +const gunzip = promisify(zlibGunzip); + +export const UNIT_TEST_KEY_PREFIX: string = process.env.UNIT_TEST_KEY_PREFIX || "unittest"; +export const UNIT_TEST_FILENAME: string = process.env.UNIT_TEST_FILENAME || "s3test.txt"; +export const UNIT_TEST_FILEPATH: string = process.env.UNIT_TEST_FILEPATH || ("test/" + UNIT_TEST_FILENAME); +const UNIT_TEST_KEY: string = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; +export const UNIT_TEST_LOCAL_FILE_LOCATION: string = process.env.UNIT_TEST_LOCAL_FILE_LOCATION || process.env.TEMP || "/tmp"; +export const MAX_POLL_WAIT: number = parseInt(process.env.MAX_POLL_WAIT || "0", 10) || 500; +// const LARGE_FILE_SIZE: number = parseInt(process.env.LARGE_FILE_SIZE || "0", 10) || 500000000; + +export const tagKey: string = "unittest"; +export const tagValue: string = "true"; +export const testTags = new Map([[tagKey, tagValue]]); +// These are set by the before after init() +export const defaultTags = new Map(); +export const fullTestTags = new Map([...testTags]); + +export const validateTagMap = (actual: Map, expected: Map) => { + try { + expect(actual.size, "validateTagMap actual.size").to.equal(expected.size); + for (const [key, value] of expected) { + expect(actual.has(key), `validateTagMap actual.has("${key}")`).to.equal(true); + expect(actual.get(key), `validateTagMap actual.get("${key}")`).to.equal(value); + } + } catch (error) { + log("validateTagMap Error", LogLevel.ERROR, error, { actual: [...actual], expected: [...expected] }); + throw error; + } +}; + +export const validateTagSet = (actual: S3Tag[], expected: Map) => { + const actualMap = new Map(); + try { + expect(actual.length, "validateTagSet actual.length").to.equal(expected.size); + for (const actualTag of actual) { + expect(actualTag.Key, "actualTag.Key").to.not.equal(undefined); + expect(actualTag.Value, "actualTag.Value").to.not.equal(undefined); + actualMap.set(actualTag.Key!, actualTag.Value!); + } + } catch (error) { + log("validateTagSet Error", LogLevel.ERROR, error, { actual, expected: [...expected] }); + throw error; + } + validateTagMap(actualMap, expected); +}; + +describe("S3Util Integration", () => { + let s3FileKey: string | undefined; + let healthCheckDate: Date | undefined; + let tagKey: string; + let tagValue: string; + + before (async () => { + // This test was failing until we reset everything. I don't know why and it bothers me. + s3Config.s3Client = undefined as any; + initS3(); + if (ADDITIONAL_TAGS_ON_ALL.size > 0) { + for (const [key, value] of ADDITIONAL_TAGS_ON_ALL) { + if (!tagKey) { + tagKey = key; + tagValue = value; + } + defaultTags.set(key, value); + fullTestTags.set(key, value); + } + } else { + tagKey = "application"; + tagValue = util.APPLICATION_NAME; + defaultTags.set(tagKey, tagValue); + fullTestTags.set(tagKey, tagValue); + } + log("tags", LogLevel.DEBUG, { tags: Array.from(testTags.entries()), defaultTags: Array.from(defaultTags.entries()), allTags: Array.from(fullTestTags.entries()) }); + // Set the access callback to test that healthchecks will be updated + setAccessCallback((date: Date) => healthCheckDate = date); + try { + await Promise.all([ + `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`, + `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}` + ].map((s3Path: string) => deleteObject(s3Path).catch((error) => + log("S3Util Integration before delete failed: " + s3Path, LogLevel.DEBUG, error)))); + } catch (error) { + // Swallow + } + }); + + beforeEach(() => { + // Set the access callback back undefined + healthCheckDate = undefined; + }); + + afterEach (async () => { + // test + if (s3FileKey) { + try { + await poll(async (): Promise => { + const files = await listFiles(s3FileKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + await deleteObject(s3FileKey); + s3FileKey = undefined; + } catch (error) { + log(`deleteObject ${s3FileKey} failed`, LogLevel.ERROR, error); + throw error; + } + } + // If this is still undefined the access callback failed and was not updated with the last access date + log("afterEach healthCheckDate=" + healthCheckDate, healthCheckDate ? LogLevel.DEBUG : LogLevel.ERROR); + expect(healthCheckDate).to.not.equal(undefined); + }); + + describe("List Objects Empty in S3", () => { + it("List Objects should always succeed even if empty", (done: Mocha.Done) => { + listObjects({ prefix: "bogus", maxKeys: 1}).then((result: ListObjectsV2CommandOutput) => { + log(`listObjects("bogus", 1) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.Contents, "Contents").to.equal(undefined); + expect(result.KeyCount, "KeyCount").to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("List Files Empty in S3", () => { + it("List Files should always succeed even if empty", (done: Mocha.Done) => { + listFiles("bogus").then((result: S3Object[]) => { + log(`listFiles("bogus") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload Object to S3", () => { + it("Upload a test object to S3", (done: Mocha.Done) => { + const baseName: string = path.basename(UNIT_TEST_FILEPATH); + const s3File: S3File = { + body: createReadStream(UNIT_TEST_FILEPATH), + key: `${UNIT_TEST_KEY_PREFIX}/${baseName}`, + contentType: "application/json" + }; + uploadObject(s3File).then((result: CompleteMultipartUploadCommandOutput) => { + log(`uploadObject result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + s3FileKey = s3File.key; + expect(result).to.not.equal(undefined); + expect(result.Location).to.not.equal(undefined); + expect(result.Location).to.include(s3FileKey); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File to S3", () => { + it("Upload a test file to S3", (done: Mocha.Done) => { + uploadFile({ filepath: UNIT_TEST_FILEPATH, s3Folder: UNIT_TEST_KEY_PREFIX }).then((url: string) => { + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + expect(url).to.not.equal(undefined); + expect(url).to.include(s3FileKey); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File Contents to S3", () => { + it("Upload a test string to S3", (done: Mocha.Done) => { + const filename: string = path.basename(UNIT_TEST_FILEPATH); + uploadFileContents({ contents: "test", filename, s3Folder: UNIT_TEST_KEY_PREFIX }).then((url: string) => { + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + expect(url).to.not.equal(undefined); + expect(url).to.include(s3FileKey); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("List Files in S3", () => { + beforeEach (async () => { + try { + const url: string = await uploadFile({ filepath: UNIT_TEST_FILEPATH, s3Folder: UNIT_TEST_KEY_PREFIX }); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + } catch(error) { + log(`beforeEach error uploadFile(${UNIT_TEST_FILEPATH}, ${UNIT_TEST_KEY_PREFIX})`, LogLevel.ERROR, error); + throw error; + } + }); + + it("List Files should return files", (done: Mocha.Done) => { + listFiles(UNIT_TEST_KEY_PREFIX).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.greaterThan(0); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("List Files with extension should return files", (done: Mocha.Done) => { + listFiles({ + s3Folder: UNIT_TEST_KEY_PREFIX, + extension: UNIT_TEST_FILENAME.slice(-3) + }).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}", undefined, ${UNIT_TEST_FILENAME.slice(-3)}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(1); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("List Files with not found extension should not return files", (done: Mocha.Done) => { + listFiles({ + s3Folder: UNIT_TEST_KEY_PREFIX, + extension: "bad" + }).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}", undefined, "bad") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Get Objects in S3", () => { + let lastModified: Date; + let expectedTags: Map; + + beforeEach (async () => { + try { + const url: string = await uploadFile({ + filepath: UNIT_TEST_FILEPATH, + s3Folder: UNIT_TEST_KEY_PREFIX, + tags: testTags + }); + lastModified = new Date(); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + const s3Object: GetObjectCommandOutput = await getObject(s3FileKey); + expect(s3Object, "s3Object").to.not.equal(undefined); + expect(s3Object.LastModified, "LastModified").to.not.equal(undefined); + expect(s3Object.TagCount, "TagCount").to.equal(fullTestTags.size); + lastModified = s3Object.LastModified!; + log("getObject beforeEach s3Object", LogLevel.DEBUG, { ...s3Object, Body: undefined }); + expectedTags = new Map(fullTestTags); + } catch (error) { + log("getObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + it("Get Object should return files", (done: Mocha.Done) => { + if (s3FileKey) { + getObject(s3FileKey).then(async (result: GetObjectCommandOutput | undefined) => { + log(`getObject(${s3FileKey}) result`, LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.Body).to.not.equal(undefined); + // The defined one and the expected on all objects + expect(result?.TagCount).to.equal(expectedTags.size); + if (result!.ContentEncoding === "gzip" && result!.Body && typeof result!.Body != "string") { + const body: Buffer = Buffer.from(await result!.Body.transformToByteArray()); + const zresult: Buffer = await gunzip(body); + log(`result.Body = ${zresult.toString()}`, LogLevel.DEBUG); + done(); + } else { + log(`result.Body = ${await result!.Body!.transformToString()}`, LogLevel.DEBUG); + done(); + } + }).catch((error) => { + done(error); + }); + } else { + done("No s3FileKey"); + } + }); + + it("Get Object should return changed files", (done: Mocha.Done) => { + if (s3FileKey) { + const testModified: Date = new Date(lastModified.getTime() - 1000); + getObject(s3FileKey, testModified).then((result: GetObjectCommandOutput | undefined) => { + log(`getObject(${s3FileKey}) result`, LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.Body).to.not.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + } else { + done("No s3FileKey"); + } + }); + + it("Get Object should not return unchanged files", (done: Mocha.Done) => { + if (s3FileKey) { + const testModified: Date = new Date(lastModified.getTime()); + log("Get Object should not return unchanged files", LogLevel.DEBUG, { lastModified, testModified }); + getObject(s3FileKey, testModified).then((result: GetObjectCommandOutput | undefined) => { + log("Should not succeed. We should get a Not Modified", LogLevel.WARN, { ...result, Body: undefined }); + done(new Error("Should not succeed. We should get a Not Modified")); + }).catch((error) => { + log(`getObject(${s3FileKey}) error = ${error}`, LogLevel.DEBUG, error); + try { + expect(error, "error").to.not.equal(undefined); + expect(error?.name, "error?.name").to.equal("304"); + done(); + } catch (error2) { + done(error2); + } + }); + } else { + done("No s3FileKey"); + } + }); + }); + + describe("Get Files in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + let localFile: string | undefined; + + beforeEach (async () => { + lastModified = new Date(); + try { + const url: string = await uploadFile({ filepath: UNIT_TEST_FILEPATH, s3Folder: UNIT_TEST_KEY_PREFIX }); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const files = await listFiles(s3FileKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + const s3Object: GetObjectCommandOutput = await getObject(s3FileKey); + expect(s3Object).to.not.equal(undefined); + expect(s3Object.LastModified).to.not.equal(undefined); + lastModified = s3Object.LastModified!; + } catch (error) { + log("getFile beforeEach error", LogLevel.ERROR, error); + throw error; + } + localFile = undefined; + }); + + afterEach (async () => { + // Delete the local file + if(localFile) { + await fs.unlink(localFile) + .catch((error) => log("Could not delete " + localFile, LogLevel.WARN, error)); + } + }); + + it("Get File should return files", (done: Mocha.Done) => { + if (s3FileKey) { + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${testFilename}) result = ${lastModified}`, LogLevel.DEBUG); + expect(downloadedLastModified).to.not.equal(undefined); + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + it("Get File should return changed files", (done: Mocha.Done) => { + if (s3FileKey) { + const testModified: Date = new Date(lastModified.getTime() - 1000); + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + lastModified: testModified + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${s3FileKey}) result = ${JSON.stringify(downloadedLastModified)}`, LogLevel.DEBUG); + expect(downloadedLastModified).to.not.equal(undefined); + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + it("Get File should not return unchanged files", (done: Mocha.Done) => { + if (s3FileKey) { + const testModified: Date = new Date(lastModified.getTime()); + log("Get File should not return unchanged files", LogLevel.DEBUG, { lastModified, testModified }); + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + lastModified: testModified + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${s3FileKey}) result = ${JSON.stringify(downloadedLastModified)}`, LogLevel.DEBUG); + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + expect(downloadedLastModified, "downloadedLastModified").to.equal(undefined); + localFile = undefined; + fs.stat(path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename)).then((stats: Stats) => { + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + log(`fs.stat(${testFilename}) stats`, LogLevel.WARN, { stats }); + expect(stats).to.equal(undefined); + done(new Error("Should not have found a file")); + }).catch((_error) => { + // We shouldn't have a stats object + done(); + }); + }).catch((error) => { + log("Get File should not return unchanged files", LogLevel.WARN, error); + done(error); + }); + } else { + done("No s3FileKey"); + } + }); + }); + + describe("Get File Contents in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + const expectedContents: string = "This is only a test"; + + beforeEach (async () => { + const url: string = await uploadFileContents({ + contents: expectedContents, + filename: UNIT_TEST_FILEPATH, + s3Folder: UNIT_TEST_KEY_PREFIX + }); + lastModified = new Date(); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const files = await listFiles(s3FileKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + const s3Object: GetObjectCommandOutput = await getObject(s3FileKey); + expect(s3Object).to.not.equal(undefined); + expect(s3Object.LastModified).to.not.equal(undefined); + lastModified = s3Object.LastModified!; + }); + + it("getFileContents should return contents", (done: Mocha.Done) => { + getFileContents({ filename: testFilename, s3Folder: UNIT_TEST_KEY_PREFIX }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents maxLength should return contents", (done: Mocha.Done) => { + getFileContents({ filename: testFilename, s3Folder: UNIT_TEST_KEY_PREFIX, maxLength: 5000 }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents maxLength should truncate contents", (done: Mocha.Done) => { + const maxLength = 5; + getFileContents({ filename: testFilename, s3Folder: UNIT_TEST_KEY_PREFIX, maxLength }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.not.equal(undefined); + expect(contents?.length).to.equal(maxLength); + expect(contents).to.equal(expectedContents.substring(0, maxLength)); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents should return changed contents", (done: Mocha.Done) => { + const testModified: Date = new Date(lastModified.getTime() - 1000); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + lastModified: testModified + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents should not return unchanged contents", (done: Mocha.Done) => { + const testModified: Date = new Date(lastModified.getTime()); + log("getFileContents should not return unchanged contents", LogLevel.DEBUG, { lastModified, testModified }); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + lastModified: testModified + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(undefined); + done(); + }).catch((error) => { + log("getFileContents should not return unchanged contents", LogLevel.WARN, error, { lastModified, testModified }); + done(error); + }); + }); + }); + + describe("Copy Objects in S3", () => { + let s3CopyKey: string | undefined; + let lastModified: Date; + const filename: string = UNIT_TEST_FILENAME; + const sourceS3Folder: string = UNIT_TEST_KEY_PREFIX; + const destinationS3Folder: string = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_KEY_PREFIX}`; + let expectedObject: GetObjectCommandOutput; + let expectedTags: Map; + + beforeEach (async () => { + try { + const url: string = await uploadFile({ + filepath: UNIT_TEST_FILEPATH, + s3Folder: sourceS3Folder, + tags: testTags + }); + lastModified = new Date(); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${sourceS3Folder}/${filename}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + expectedObject = await getObject(s3FileKey); + expect(expectedObject, "actualObject").to.not.equal(undefined); + expect(expectedObject.LastModified, "LastModified").to.not.equal(undefined); + expect(expectedObject.ContentType, "ContentType").to.not.equal(undefined); + expect(expectedObject.ContentEncoding, "ContentEncoding").to.not.equal(undefined); + expect(expectedObject.CacheControl, "CacheControl").to.not.equal(undefined); + expect(expectedObject.TagCount, "TagCount").to.equal(fullTestTags.size); + const tagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "tagging.TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, fullTestTags); + lastModified = expectedObject.LastModified!; + expectedTags = new Map(fullTestTags); + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + afterEach (async () => { + // test + if (s3CopyKey) { + try { + await poll(async (): Promise => { + const files = await listFiles(s3CopyKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3CopyKey} in s3`); + const actualObject: GetObjectCommandOutput = await getObject(s3CopyKey); + expect(actualObject, "actualObject").to.not.equal(undefined); + expect(actualObject.LastModified, "actualObject.LastModified").to.not.equal(undefined); + expect(actualObject.LastModified!.getTime(), "actualObject.LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + expect(actualObject.ContentType, "actualObject.ContentType").to.equal(expectedObject.ContentType); + expect(actualObject.ContentEncoding, "actualObject.ContentEncoding").to.equal(expectedObject.ContentEncoding); + expect(actualObject.CacheControl, "actualObject.CacheControl").to.equal(expectedObject.CacheControl); + expect(actualObject.TagCount, "actualObject.TagCount").to.equal(expectedTags.size); + const actualTagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3CopyKey); + expect(actualTagging.TagSet, "TagSet").to.not.equal(undefined); + validateTagSet(actualTagging.TagSet!, expectedTags); + await deleteObject(s3CopyKey); + s3CopyKey = undefined; + } catch (error) { + log(`deleteObject ${s3CopyKey} failed`, LogLevel.ERROR, error); + throw error; + } + } + }); + + it("Copy Object should copy object and properties", (done: Mocha.Done) => { + if (s3FileKey) { + const sourceFile: S3File = { + key: s3FileKey, + contentType: "application/json" + }; + const destinationFile: S3File = { + key: `${destinationS3Folder}/${filename}`, + contentType: "application/json" + }; + + copyObject({ sourceFile, destinationFile }).then((result: CopyObjectCommandOutput | undefined) => { + log(`copyObject(${s3FileKey}, ${destinationFile.key}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + s3CopyKey = destinationFile.key; + expect(result, "result").to.not.equal(undefined); + expect(result?.CopyObjectResult, "CopyObjectResult").to.not.equal(undefined); + expect(result?.CopyObjectResult?.LastModified, "LastModified").to.not.equal(undefined); + expect(result?.CopyObjectResult?.LastModified?.getTime(), "LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + done(); + }).catch((error) => { + done(error); + }); + } else { + done("No s3FileKey"); + } + }); + + it("Copy Object should change properties", (done: Mocha.Done) => { + if (s3FileKey) { + const sourceFile: S3File = { + key: `${sourceS3Folder}/${filename}`, + contentType: "application/json" + }; + const destinationFile: S3File = { + key: `${destinationS3Folder}/${filename}`, + contentType: "text/plain" + }; + + // Change tags + expectedTags.set(tagKey, "pewpewagent"); + expectedTags.set("unittest", "false"); + expectedTags.set("additionaltag", "additionalvalue"); + const tags = new Map(expectedTags); + expect(expectedTags.size, "expectedTags.size before").to.equal(3); // Make sure there aren't some others we don't know about + + copyObject({ sourceFile, destinationFile, tags }).then((result: CopyObjectCommandOutput | undefined) => { + log(`copyObject(${s3FileKey}, ${destinationFile.key}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + s3CopyKey = destinationFile.key; + expect(result).to.not.equal(undefined); + expect(result?.CopyObjectResult, "CopyObjectResult").to.not.equal(undefined); + expect(result?.CopyObjectResult?.LastModified, "LastModified").to.not.equal(undefined); + expect(result?.CopyObjectResult?.LastModified?.getTime(), "LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + expectedObject.ContentType = "text/plain"; + done(); + }).catch((error) => { + done(error); + }); + } else { + done("No s3FileKey"); + } + }); + }); + + describe("Copy Files in S3", () => { + let s3CopyKey: string | undefined; + let lastModified: Date; + const filename: string = UNIT_TEST_FILENAME; + const sourceS3Folder: string = UNIT_TEST_KEY_PREFIX; + const destinationS3Folder: string = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_KEY_PREFIX}`; + const destinationFilename: string = "bogus.txt"; + let expectedObject: GetObjectCommandOutput; + let expectedTags: Map; + + beforeEach (async () => { + try { + const url: string = await uploadFile({ filepath: UNIT_TEST_FILEPATH, s3Folder: sourceS3Folder, tags: testTags }); + lastModified = new Date(); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${sourceS3Folder}/${filename}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + expectedObject = await getObject(s3FileKey); + expect(expectedObject, "actualObject").to.not.equal(undefined); + expect(expectedObject.LastModified, "LastModified").to.not.equal(undefined); + expect(expectedObject.ContentType, "ContentType").to.not.equal(undefined); + expect(expectedObject.ContentEncoding, "ContentEncoding").to.not.equal(undefined); + expect(expectedObject.CacheControl, "CacheControl").to.not.equal(undefined); + expect(expectedObject.TagCount, "TagCount").to.equal(fullTestTags.size); + const tagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "tagging.TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, fullTestTags); + lastModified = expectedObject.LastModified!; + expectedTags = new Map(fullTestTags); + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + afterEach (async () => { + // test + if (s3CopyKey) { + try { + await poll(async (): Promise => { + const files = await listFiles(s3CopyKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3CopyKey} in s3`); + const actualObject: GetObjectCommandOutput = await getObject(s3CopyKey); + expect(actualObject, "actualObject").to.not.equal(undefined); + expect(actualObject.LastModified, "actualObject.LastModified").to.not.equal(undefined); + expect(actualObject.LastModified!.getTime(), "actualObject.LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + expect(actualObject.ContentType, "actualObject.ContentType").to.equal(expectedObject.ContentType); + expect(actualObject.ContentEncoding, "actualObject.ContentEncoding").to.equal(expectedObject.ContentEncoding); + expect(actualObject.CacheControl, "actualObject.CacheControl").to.equal(expectedObject.CacheControl); + expect(actualObject.TagCount, "actualObject.TagCount").to.equal(expectedTags.size); + const actualTagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3CopyKey); + expect(actualTagging.TagSet, "TagSet").to.not.equal(undefined); + validateTagSet(actualTagging.TagSet!, expectedTags); + await deleteObject(s3CopyKey); + s3CopyKey = undefined; + } catch (error) { + log(`deleteObject ${s3CopyKey} failed`, LogLevel.ERROR, error); + throw error; + } + } + }); + + it("Copy File should return files", (done: Mocha.Done) => { + if (s3FileKey) { + copyFile({ filename, sourceS3Folder, destinationS3Folder }).then((downloadedLastModified: Date | undefined) => { + s3CopyKey = `${destinationS3Folder}/${filename}`; + log(`copyFile({ ${filename}, ${sourceS3Folder}, ${destinationS3Folder} }) result = ${downloadedLastModified}`, LogLevel.DEBUG); + expect(downloadedLastModified, "LastModified").to.not.equal(undefined); + expect(downloadedLastModified?.getTime(), "LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + done(); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + it("Copy File should change name", (done: Mocha.Done) => { + if (s3FileKey) { + // Change tags + expectedTags.set(tagKey, "pewpewagent"); + expectedTags.set("unittest", "false"); + expectedTags.set("additionaltag", "additionalvalue"); + const tags = new Map(expectedTags); + expect(expectedTags.size, "expectedTags.size before").to.equal(3); // Make sure there aren't some others we don't know about + + copyFile({ filename, sourceS3Folder, destinationS3Folder, destinationFilename, tags }).then((downloadedLastModified: Date | undefined) => { + s3CopyKey = `${destinationS3Folder}/${destinationFilename}`; + log(`copyFile({ ${filename}, ${sourceS3Folder}, ${destinationS3Folder} }) result = ${downloadedLastModified}`, LogLevel.DEBUG); + expect(downloadedLastModified, "LastModified").to.not.equal(undefined); + expect(downloadedLastModified?.getTime(), "LastModified").to.be.greaterThanOrEqual(lastModified.getTime()); + done(); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + }); + + describe("Get Object Tagging in S3", () => { + const filename: string = UNIT_TEST_FILENAME; + const sourceS3Folder: string = UNIT_TEST_KEY_PREFIX; + let expectedObject: GetObjectCommandOutput; + let expectedTags: Map; + + describe("Get Object Tagging populated", () => { + + beforeEach (async () => { + try { + const url: string = await uploadFile({ + filepath: UNIT_TEST_FILEPATH, + s3Folder: sourceS3Folder, + tags: testTags + }); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${sourceS3Folder}/${filename}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + expectedObject = await getObject(s3FileKey); + expect(expectedObject, "actualObject").to.not.equal(undefined); + expect(expectedObject.LastModified, "LastModified").to.not.equal(undefined); + expect(expectedObject.ContentType, "ContentType").to.not.equal(undefined); + expect(expectedObject.ContentEncoding, "ContentEncoding").to.not.equal(undefined); + expect(expectedObject.CacheControl, "CacheControl").to.not.equal(undefined); + expect(expectedObject.TagCount, "TagCount").to.equal(fullTestTags.size); + const tagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "tagging.TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, fullTestTags); + expectedTags = new Map(fullTestTags); + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + it("getObjectTagging should get a tag", (done: Mocha.Done) => { + getObjectTagging(UNIT_TEST_KEY).then((result: GetObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + expect(result.TagSet, "result.TagSet").to.not.equal(undefined); + validateTagSet(result.TagSet!, expectedTags); + done(); + }).catch((error) => done(error)); + }); + + it("getTags should get a tag", (done: Mocha.Done) => { + getTags({ + filename: UNIT_TEST_FILENAME, + s3Folder: UNIT_TEST_KEY_PREFIX + }).then((result: Map | undefined) => { + expect(result).to.not.equal(undefined); + validateTagMap(result!, expectedTags); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("Get Object Tagging empty", () => { + beforeEach (async () => { + try { + const url: string = await uploadFile({ + filepath: UNIT_TEST_FILEPATH, + s3Folder: sourceS3Folder, + tags: undefined + }); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${sourceS3Folder}/${filename}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + expectedObject = await getObject(s3FileKey); + expect(expectedObject, "actualObject").to.not.equal(undefined); + expect(expectedObject.LastModified, "LastModified").to.not.equal(undefined); + expect(expectedObject.ContentType, "ContentType").to.not.equal(undefined); + expect(expectedObject.ContentEncoding, "ContentEncoding").to.not.equal(undefined); + expect(expectedObject.CacheControl, "CacheControl").to.not.equal(undefined); + // Default only + expect(expectedObject.TagCount, "TagCount").to.equal(defaultTags.size); + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + it("getObjectTagging should get no tags", (done: Mocha.Done) => { + getObjectTagging(UNIT_TEST_KEY).then((result: GetObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + expect(result.TagSet).to.not.equal(undefined); + // There's always the default set + expect(result.TagSet, "result.TagSet").to.not.equal(undefined); + validateTagSet(result.TagSet!, defaultTags); + done(); + }).catch((error) => done(error)); + }); + + it("getTags should get no tags", (done: Mocha.Done) => { + getTags({ + filename: UNIT_TEST_FILENAME, + s3Folder: UNIT_TEST_KEY_PREFIX + }).then((result: Map | undefined) => { + expect(result).to.not.equal(undefined); + // There's always the default set + validateTagMap(result!, defaultTags); + done(); + }).catch((error) => done(error)); + }); + }); + }); + + describe("Put Object Tagging in S3", () => { + const filename: string = UNIT_TEST_FILENAME; + const s3Folder: string = UNIT_TEST_KEY_PREFIX; + let expectedTags: Map; + + beforeEach (async () => { + try { + const uploadTags = new Map(testTags); + // Change it so clearing will set it back + uploadTags.set(tagKey, "pewpewagent"); + const url: string = await uploadFile({ + filepath: UNIT_TEST_FILEPATH, + s3Folder, + tags: uploadTags + }); + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + s3FileKey = `${s3Folder}/${filename}`; + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const objects = await listObjects(s3FileKey!); + return (objects && objects.Contents && objects.Contents.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + const expectedObject = await getObject(s3FileKey); + expect(expectedObject, "actualObject").to.not.equal(undefined); + expect(expectedObject.TagCount, "TagCount").to.equal(uploadTags.size); + const tagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "tagging.TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, uploadTags); + expectedTags = new Map(uploadTags); + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + afterEach (async () => { + try { + if (s3FileKey) { + const tagging: GetObjectTaggingCommandOutput = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "tagging.TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, expectedTags); + } + } catch (error) { + log("copyObject beforeEach error", LogLevel.ERROR, error); + throw error; + } + }); + + it("putObjectTagging should put a tag", (done: Mocha.Done) => { + expectedTags.set("additionalTag", "additionalValue"); + const tags = new Map(expectedTags); + putObjectTagging({ key: s3FileKey!, tags }).then((result: PutObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + + it("putTags should put a tag", (done: Mocha.Done) => { + expectedTags.set("additionalTag", "additionalValue"); + const tags = new Map(expectedTags); + putTags({ filename, s3Folder, tags }).then(() => { + done(); + }).catch((error) => done(error)); + }); + + it("putObjectTagging should clear tags", (done: Mocha.Done) => { + const tags = new Map(); + expectedTags = new Map(defaultTags); // default will be set back + putObjectTagging({ key: s3FileKey!, tags }).then((result: PutObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + + it("putTags should clear tags", (done: Mocha.Done) => { + const tags = new Map(); + expectedTags = new Map(defaultTags); // default will be set back + putTags({ filename, s3Folder, tags }).then(() => { + done(); + }).catch((error) => done(error)); + }); + }); + + // describe("Copy Files Performance", () => { + // let s3CopyKey: string | undefined; + // const filename: string = UNIT_TEST_FILENAME; + // const sourceS3Folder: string = UNIT_TEST_KEY_PREFIX; + // const destinationS3Folder: string = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_KEY_PREFIX}`; + // const expectedContents: string = "a".repeat(LARGE_FILE_SIZE); + + // beforeEach (async () => { + // try { + // const timeBefore = Date.now(); + // await uploadFileContents(expectedContents, UNIT_TEST_FILEPATH, UNIT_TEST_KEY_PREFIX); + // log("uploadFileContents duration: " + (Date.now() - timeBefore), LogLevel.WARN); + // s3FileKey = `${sourceS3Folder}/${filename}`; + // // As long as we don't throw, it passes + // // Need time for eventual consistency to complete + // await poll(async (): Promise => { + // const objects = await listObjects(s3FileKey!); + // return (objects && objects.Contents && objects.Contents.length > 0); + // }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + // } catch (error) { + // log("copyObject beforeEach error", LogLevel.ERROR, error); + // throw error; + // } + // }); + + // afterEach (async () => { + // // test + // if (s3CopyKey) { + // try { + // await poll(async (): Promise => { + // const files = await listFiles(s3CopyKey!); + // return (files && files.length > 0); + // }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3CopyKey} in s3`); + // await deleteObject(s3CopyKey); + // s3CopyKey = undefined; + // } catch (error) { + // log(`deleteObject ${s3CopyKey} failed`, LogLevel.ERROR, error); + // throw error; + // } + // } + // }); + + // it("Copy File should return files", (done: Mocha.Done) => { + // if (s3FileKey) { + // const timeBefore = Date.now(); + // copyFile({ filename, sourceS3Folder, destinationS3Folder }).then((downloadedLastModified: Date | undefined) => { + // log("copyFile duration: " + (Date.now() - timeBefore), LogLevel.WARN); + // s3CopyKey = `${destinationS3Folder}/${filename}`; + // log(`copyFile({ ${filename}, ${sourceS3Folder}, ${destinationS3Folder} }) result = ${downloadedLastModified}`, LogLevel.DEBUG); + // expect(downloadedLastModified, "LastModified").to.not.equal(undefined); + // done(); + // }).catch((error) => done(error)); + // } else { + // done("No s3FileKey"); + // } + // }); + // }); +}); diff --git a/common/integration/s3file.spec.ts b/common/integration/s3file.spec.ts new file mode 100644 index 00000000..b7d3264d --- /dev/null +++ b/common/integration/s3file.spec.ts @@ -0,0 +1,458 @@ +import * as path from "path"; +import { + BUCKET_URL, + KEYSPACE_PREFIX, + deleteObject, + getObjectTagging, + listFiles +} from "../src/util/s3"; +import { + LogLevel, + PpaasS3File, + PpaasS3FileOptions, + PpaasTestId, + log +} from "../src/index"; +import { + MAX_POLL_WAIT, + UNIT_TEST_FILENAME, + UNIT_TEST_FILEPATH, + UNIT_TEST_LOCAL_FILE_LOCATION, + fullTestTags, + testTags, + validateTagMap, + validateTagSet +} from "./s3.spec"; +import { _Object as S3Object } from "@aws-sdk/client-s3"; +import { Stats } from "fs"; +import { expect } from "chai"; +import fs from "fs/promises"; +import { poll } from "../src/util/util"; + +class PpaasS3FileUnitTest extends PpaasS3File { + public constructor (options: PpaasS3FileOptions) { + super(options); + } + + public getLastModifiedLocal (): number { + return this.lastModifiedLocal; + } + + public setLastModifiedLocal (lastModifiedLocal: number) { + this.lastModifiedLocal = lastModifiedLocal; + } + + public getLastModifiedRemote (): Date { + return this.lastModifiedRemote; + } + + public setLastModifiedRemote (lastModifiedRemote: Date) { + this.lastModifiedRemote = lastModifiedRemote; + } +} + +describe("PpaasS3File Integration", () => { + let s3FileKey: string | undefined; + let testPpaasS3FileUpload: PpaasS3FileUnitTest; + let testPpaasS3FileDownload: PpaasS3FileUnitTest; + let unitTestKeyPrefix: string; + let expectedTags: Map; + + before (() => { + const ppaasTestId = PpaasTestId.makeTestId(UNIT_TEST_FILENAME); + unitTestKeyPrefix = ppaasTestId.s3Folder; + }); + + beforeEach (() => { + testPpaasS3FileUpload = new PpaasS3FileUnitTest({ + filename: UNIT_TEST_FILENAME, + s3Folder: unitTestKeyPrefix, + localDirectory: path.dirname(UNIT_TEST_FILEPATH), + tags: testTags + }); + testPpaasS3FileDownload = new PpaasS3FileUnitTest({ + filename: UNIT_TEST_FILENAME, + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + tags: testTags + }); + expectedTags = new Map(fullTestTags); + }); + + afterEach (async () => { + // test + if (s3FileKey) { + try { + // Need time for eventual consistency to complete + await poll(async (): Promise => { + const files = await listFiles(s3FileKey!); + return (files && files.length > 0); + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + const tagging = await getObjectTagging(s3FileKey); + expect(tagging.TagSet, "TagSet").to.not.equal(undefined); + validateTagSet(tagging.TagSet!, expectedTags); + await deleteObject(s3FileKey); + s3FileKey = undefined; + } catch (error) { + log(`deleteObject ${s3FileKey} failed`, LogLevel.ERROR, error); + throw error; + } + } + }); + + describe("existsLocal should work", () => { + it("testPpaasS3FileUpload should exist local", (done: Mocha.Done) => { + testPpaasS3FileUpload.existsLocal().then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("testPpaasS3FileDownload should not exist local", (done: Mocha.Done) => { + testPpaasS3FileDownload.existsLocal().then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + + it("testPpaasS3FileDownload should not exist inS3", (done: Mocha.Done) => { + testPpaasS3FileUpload.existsInS3().then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + + it("PpaasS3File.existsInS3 should not exist inS3", (done: Mocha.Done) => { + PpaasS3File.existsInS3(testPpaasS3FileUpload.key).then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + }); + + describe("List PpaasS3File Empty in S3", () => { + it("List PpaasS3File should always succeed even if empty", (done: Mocha.Done) => { + PpaasS3File.getAllFilesInS3({ s3Folder: "bogus", localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("bogus", ${UNIT_TEST_LOCAL_FILE_LOCATION}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File to S3", () => { + let lastModified: number; + beforeEach (async () => { + try { + const stats: Stats = await fs.stat(testPpaasS3FileUpload.localFilePath); + lastModified = stats.mtimeMs; + testPpaasS3FileUpload.setLastModifiedLocal(0); + testPpaasS3FileUpload.remoteUrl = ""; + // As long as we don't throw, it passes + } catch(error) { + throw error; + } + }); + + it("Upload a test file to S3", (done: Mocha.Done) => { + testPpaasS3FileUpload.upload().then(() => { + log("testPpaasS3FileUpload.upload succeeded}", LogLevel.DEBUG); + s3FileKey = testPpaasS3FileUpload.key; + // we should upload it and update the time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + // Hasn't been downloaded so it shouldn't be set + expect(testPpaasS3FileUpload.getLastModifiedRemote().getTime()).to.equal(new Date(0).getTime()); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Upload a test file should upload changed files", (done: Mocha.Done) => { + testPpaasS3FileUpload.setLastModifiedLocal(lastModified - 1000); + testPpaasS3FileUpload.upload().then(() => { + log("testPpaasS3FileDownload.upload() succeeded", LogLevel.DEBUG); + s3FileKey = testPpaasS3FileUpload.key; + // If it's older we should upload it and update the time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + done(); + }).catch((error) => done(error)); + }); + + it("Upload a test file should not upload unchanged files", (done: Mocha.Done) => { + testPpaasS3FileUpload.setLastModifiedLocal(lastModified); // It checks exact + testPpaasS3FileUpload.upload().then(() => { + s3FileKey = undefined; + log("testPpaasS3FileDownload.upload() succeeded", LogLevel.DEBUG); + // If it's newer we should not upload it and keep the same time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.equal(""); + listFiles(testPpaasS3FileUpload.key).then((s3Files: S3Object[] | undefined) => { + expect(s3Files).to.not.equal(undefined); + expect(s3Files!.length).to.equal(0); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + + it("Upload a test file force should upload unchanged files", (done: Mocha.Done) => { + testPpaasS3FileUpload.setLastModifiedLocal(lastModified); + testPpaasS3FileUpload.upload(true).then(() => { + s3FileKey = testPpaasS3FileUpload.key; + log("testPpaasS3FileDownload.upload(true) succeeded", LogLevel.DEBUG); + // If it's newer, but forced we should upload it and set the time to last modified + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + listFiles(testPpaasS3FileUpload.key).then((s3Files: S3Object[] | undefined) => { + expect(s3Files).to.not.equal(undefined); + expect(s3Files!.length).to.equal(1); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + }); + + describe("List Files in S3", () => { + beforeEach (async () => { + try { + await testPpaasS3FileUpload.upload(true); + log("testPpaasS3FileUpload.upload() succeeded", LogLevel.DEBUG); + s3FileKey = testPpaasS3FileUpload.key; + // As long as we don't throw, it passes + } catch(error) { + throw error; + } + }); + + it("testPpaasS3FileDownload should exist inS3", (done: Mocha.Done) => { + testPpaasS3FileUpload.existsInS3().then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("PpaasS3File.existsInS3 should exist inS3", (done: Mocha.Done) => { + PpaasS3File.existsInS3(testPpaasS3FileUpload.key).then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("getAllFilesInS3 should return files", (done: Mocha.Done) => { + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.greaterThan(0); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].remoteUrl).to.not.equal(""); + expect(result[0].remoteUrl).to.include(`${BUCKET_URL}/${KEYSPACE_PREFIX}${unitTestKeyPrefix}/${UNIT_TEST_FILENAME}`); + expect(result[0].tags).to.not.equal(undefined); + validateTagMap(result[0].tags!, expectedTags); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("getAllFilesInS3 partial folder should return files", (done: Mocha.Done) => { + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.greaterThan(0); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].s3Folder).to.equal(unitTestKeyPrefix); + expect(result[0].filename).to.equal(UNIT_TEST_FILENAME); + expect(result[0].remoteUrl).to.not.equal(""); + expect(result[0].remoteUrl).to.include(`${BUCKET_URL}/${KEYSPACE_PREFIX}${unitTestKeyPrefix}/${UNIT_TEST_FILENAME}`); + expect(result[0].tags).to.not.equal(undefined); + validateTagMap(result[0].tags!, expectedTags); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("getAllFilesInS3 partial folder by extension should return files", (done: Mocha.Done) => { + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + extension: UNIT_TEST_FILENAME.slice(-3) + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(1); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].tags).to.not.equal(undefined); + validateTagMap(result[0].tags!, expectedTags); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("getAllFilesInS3 partial folder wrong extension should not return files", (done: Mocha.Done) => { + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + extension: "bad", + maxFiles: 1000 + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}", 1000) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Get Files in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + let localFile: string | undefined; + beforeEach (async () => { + await testPpaasS3FileUpload.upload(true, true); + s3FileKey = testPpaasS3FileUpload.key; + // Reset this between tests + testPpaasS3FileDownload.setLastModifiedRemote(new Date(0)); + // As long as we don't throw, it passes + // Need time for eventual consistency to complete + const s3Files: S3Object[] | undefined = await poll(async (): Promise => { + const files = await listFiles(s3FileKey!); + return (files && files.length > 0) ? files : undefined; + }, MAX_POLL_WAIT, (errMsg: string) => `${errMsg} Could not find the ${s3FileKey} in s3`); + if (s3Files && s3Files.length > 0) { + lastModified = s3Files[0].LastModified || new Date(); + } else { + lastModified = new Date(); // Set the time to now + } + }); + + afterEach (async () => { + // Delete the local file + if(localFile) { + await fs.unlink(localFile) + .catch((error) => log("Could not delete " + localFile, LogLevel.WARN, error)); + } + }); + + it("Get File should return files", (done: Mocha.Done) => { + if (s3FileKey) { + expect(testPpaasS3FileDownload.tags?.size, "testPpaasS3FileDownload.tags?.size before").to.equal(testTags.size); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + localFile = testPpaasS3FileDownload.localFilePath; + expect(testPpaasS3FileDownload.tags, "testPpaasS3FileDownload.tags after").to.not.equal(undefined); + validateTagMap(testPpaasS3FileDownload.tags!, expectedTags); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + it("Get File should return changed files", (done: Mocha.Done) => { + if (s3FileKey) { + expect(testPpaasS3FileDownload.tags?.size, "testPpaasS3FileDownload.tags?.size before").to.equal(testTags.size); + // Set it before the last modified so it's changed + testPpaasS3FileDownload.setLastModifiedRemote(new Date(lastModified.getTime() - 1000)); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should not be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = testPpaasS3FileDownload.localFilePath; + expect(testPpaasS3FileDownload.tags, "testPpaasS3FileDownload.tags after").to.not.equal(undefined); + validateTagMap(testPpaasS3FileDownload.tags!, expectedTags); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + + it("Get File should not return unchanged files", (done: Mocha.Done) => { + if (s3FileKey) { + expect(testPpaasS3FileDownload.tags?.size, "testPpaasS3FileDownload.tags?.size before").to.equal(testTags.size); + // Set it to the last modified so it's unchanged + testPpaasS3FileDownload.setLastModifiedRemote(lastModified); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = undefined; + expect(testPpaasS3FileDownload.tags, "testPpaasS3FileDownload.tags after").to.not.equal(undefined); + validateTagMap(testPpaasS3FileDownload.tags!, testTags); + fs.stat(testPpaasS3FileDownload.localFilePath).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.equal(undefined); + done(); + }).catch((_error) => { + // We shouldn't have a stats object + done(); + }); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + + }); + + it("Get File force should return unchanged files", (done: Mocha.Done) => { + if (s3FileKey) { + expect(testPpaasS3FileDownload.tags?.size, "testPpaasS3FileDownload.tags?.size before").to.equal(testTags.size); + // Set it to the last modified so it's unchanged + testPpaasS3FileDownload.setLastModifiedRemote(lastModified); + // Then force download it + testPpaasS3FileDownload.download(true).then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should not be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = testPpaasS3FileDownload.localFilePath; + expect(testPpaasS3FileDownload.tags, "testPpaasS3FileDownload.tags after").to.not.equal(undefined); + validateTagMap(testPpaasS3FileDownload.tags!, expectedTags); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + // We should have a stats object + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + } else { + done("No s3FileKey"); + } + }); + }); +}); diff --git a/common/integration/sqs.spec.ts b/common/integration/sqs.spec.ts new file mode 100644 index 00000000..59131bb5 --- /dev/null +++ b/common/integration/sqs.spec.ts @@ -0,0 +1,823 @@ +import { + ChangeMessageVisibilityCommandInput, + DeleteMessageCommandInput, + GetQueueAttributesCommandInput, + GetQueueAttributesCommandOutput, + MessageAttributeValue, + ReceiveMessageCommandInput, + ReceiveMessageCommandOutput, + Message as SQSMessage, + SendMessageCommandInput, + SendMessageCommandOutput +} from "@aws-sdk/client-sqs"; +import { LogLevel, SqsQueueType, log, util } from "../src/index"; +import { + QUEUE_URL_COMMUNICATION, + QUEUE_URL_SCALE_IN, + QUEUE_URL_TEST, + changeMessageVisibility, + changeMessageVisibilityByHandle, + cleanUpQueue, + cleanUpQueues, + deleteMessage, + deleteMessageByHandle, + deleteTestScalingMessage, + getCommunicationMessage, + getNewTestToRun, + getQueueAttributes, + getQueueAttributesMap, + getTestScalingMessage, + init as initSqs, + receiveMessage, + refreshTestScalingMessage, + sendMessage, + sendNewCommunicationsMessage, + sendNewTestToRun, + sendTestScalingMessage, + setAccessCallback, + config as sqsConfig +} from "../src/util/sqs"; + +import { expect } from "chai"; + +const { sleep } = util; + +const UNIT_TEST_KEY_PREFIX: string = process.env.UNIT_TEST_KEY_PREFIX || "unittest"; +const UNIT_TEST_FILENAME: string = process.env.UNIT_TEST_FILENAME || "s3test.txt"; +// eslint-disable-next-line eqeqeq +const TEST_CHANGE_VISIBILITY: boolean = process.env.TEST_CHANGE_VISIBILITY?.toLowerCase() == "true"; +const CHANGE_VISIBILITY_SLEEP: number = 15 * 1000; +const CHANGE_VISIBILITY_WAIT: number = 60 * 1000; + +describe("SqsUtil Integration", () => { + let expectedQueueUrlTest: string; + let expectedQueueUrlTestName: string; + let expectedQueueUrlScale: string; + let expectedQueueUrlScaleName: string; + const receiveParamsTest: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 30, + WaitTimeSeconds: 10 + }; + + const receiveParamsScale: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 0, // Don't lock anything out in the real queue + WaitTimeSeconds: 0 + }; + + const receiveParamsComm: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 5, + WaitTimeSeconds: 0 + }; + let healthCheckDate: Date | undefined; + + before(async () => { + // reset everything in case the mocks ran. + sqsConfig.sqsClient = undefined as any; + initSqs(); + log("QUEUE_URL_TEST=" + [...QUEUE_URL_TEST], LogLevel.DEBUG); + log("QUEUE_URL_SCALE=" + [...QUEUE_URL_SCALE_IN], LogLevel.DEBUG); + log("QUEUE_URL_COMMUNICATION=" + QUEUE_URL_COMMUNICATION, LogLevel.DEBUG); + const startTime = Date.now(); + const total = await cleanUpQueues(); + const duration = Date.now() - startTime; + // Get the names of the enum and create an object with name to count mapping + log(`cleanUpQueues ${total}: ${duration}ms`, LogLevel.WARN, { total, duration }); + + // Can't set these until after init. + expectedQueueUrlTest = receiveParamsTest.QueueUrl = QUEUE_URL_TEST.values().next().value; + expect(typeof expectedQueueUrlTest).to.equal("string"); + expect(expectedQueueUrlTest.length).to.be.greaterThan(0); + expectedQueueUrlTestName = QUEUE_URL_TEST.keys().next().value; + expect(typeof expectedQueueUrlTestName).to.equal("string"); + expect(expectedQueueUrlTestName.length).to.be.greaterThan(0); + expectedQueueUrlScale = receiveParamsScale.QueueUrl = QUEUE_URL_SCALE_IN.values().next().value; + expect(typeof expectedQueueUrlScale).to.equal("string"); + expect(expectedQueueUrlScale.length).to.be.greaterThan(0); + expectedQueueUrlScaleName = QUEUE_URL_SCALE_IN.keys().next().value; + expect(typeof expectedQueueUrlScaleName).to.equal("string"); + expect(expectedQueueUrlScaleName.length).to.be.greaterThan(0); + receiveParamsComm.QueueUrl = QUEUE_URL_COMMUNICATION; + log("queueUrl", LogLevel.DEBUG, { expectedQueueUrlScale, expectedQueueUrlScaleName, expectedQueueUrlTest, expectedQueueUrlTestName }); + setAccessCallback((date: Date) => healthCheckDate = date); + }); + + after(async () => { + const startTime = Date.now(); + const total = await cleanUpQueues(); + const duration = Date.now() - startTime; + // Get the names of the enum and create an object with name to count mapping + log(`cleanUpQueues ${total}: ${duration}ms`, LogLevel.WARN, { total, duration }); + }); + + describe("SQS Read/Write", () => { + beforeEach(() => { + // Set the access callback back undefined + healthCheckDate = undefined; + }); + + afterEach (() => { + // If this is still undefined the access callback failed and was not updated with the last access date + log("afterEach healthCheckDate=" + healthCheckDate, healthCheckDate ? LogLevel.DEBUG : LogLevel.ERROR); + expect(healthCheckDate).to.not.equal(undefined); + }); + + describe("Read From Test Retrieval SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + receiveMessage(receiveParamsTest).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Read From Scale In SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + receiveMessage(receiveParamsScale).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Read From Communications Retrieval SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + receiveMessage(receiveParamsComm).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Actual Messages From Communications Retrieval SQS Queue", () => { + const testId: string = "UnitTest" + Date.now(); // Used to identify OUR Unit test message + let messageHandle: string | undefined; + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: testId + }, + Recipient: { + DataType: "String", + StringValue: "UnitTest" + }, + Sender: { + DataType: "String", + StringValue: "UnitTest" + }, + Message: { + DataType: "String", + StringValue: "UnitTest" + } + }; + + before (async () => { + const sqsMessageRequest: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: "Sending Message to the Communications Queue", + QueueUrl: QUEUE_URL_COMMUNICATION + }; + log("sendMessage request", LogLevel.DEBUG, sqsMessageRequest); + await sendMessage(sqsMessageRequest); + }); + + after (async () => { + if (messageHandle) { + const sqsDeleteRequest: DeleteMessageCommandInput = { + ReceiptHandle: messageHandle, + QueueUrl: QUEUE_URL_COMMUNICATION + }; + log("deleteMessage request", LogLevel.DEBUG, sqsDeleteRequest); + await deleteMessage(sqsDeleteRequest); + } + }); + + it("ReceiveMessage Communications should receive a message", (done: Mocha.Done) => { + receiveMessage({ ...receiveParamsComm, WaitTimeSeconds: 1 }).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result, "receiveMessage result " + JSON.stringify(result)).to.not.equal(undefined); + expect(result.Messages, "receiveMessage result.messages").to.not.equal(undefined); + expect(result.Messages!.length, "receiveMessage result length " + JSON.stringify(result)).to.be.greaterThan(0); + // But we need to grab the handle for clean-up + if (result && result.Messages && result.Messages.length > 0) { + for (const message of result.Messages) { + const receivedAttributes: Record | undefined = message.MessageAttributes; + if (receivedAttributes && Object.keys(receivedAttributes).includes("TestId") && receivedAttributes["TestId"].StringValue === testId) { + messageHandle = message.ReceiptHandle; + } + } + } + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Actual Communications From Communications Retrieval SQS Queue", () => { + const testId: string = "UnitTest" + Date.now(); // Used to identify OUR Unit test message + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: testId + }, + Recipient: { + DataType: "String", + StringValue: "UnitTest" + }, + Sender: { + DataType: "String", + StringValue: "UnitTest" + }, + Message: { + DataType: "String", + StringValue: "UnitTest" + } + }; + + before (async () => { + log("sendNewCommunicationsMessage request", LogLevel.DEBUG, messageAttributes); + await sendNewCommunicationsMessage(messageAttributes); + }); + + after (async () => { + const cleanUpParams: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: QUEUE_URL_COMMUNICATION, + VisibilityTimeout: 10, + WaitTimeSeconds: 0 + }; + let result: ReceiveMessageCommandOutput = await receiveMessage(cleanUpParams); + while (result && Array.isArray(result.Messages) && result.Messages.length > 0) { + for (const message of result.Messages) { + await deleteMessageByHandle({ messageHandle: message.ReceiptHandle!, sqsQueueType: SqsQueueType.Communications }); + } + result = await receiveMessage(cleanUpParams); + } + }); + + it("getCommunicationMessages should receive a message", (done: Mocha.Done) => { + getCommunicationMessage().then((message: SQSMessage | undefined) => { + log("getCommunicationMessages result", LogLevel.DEBUG, message); + expect(message, "getCommunicationMessages result " + JSON.stringify(message)).to.not.equal(undefined); + // But we need to grab the handle for clean-up + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Send to Real SQS Test Queue", () => { + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: "UnitTest" + Date.now() + }, + S3Folder: { + DataType: "String", + StringValue: UNIT_TEST_KEY_PREFIX + }, + YamlFile: { + DataType: "String", + StringValue: UNIT_TEST_FILENAME + }, + TestRunTime: { + DataType: "String", + StringValue: "1" + } + }; + + after (async () => { + const cleanUpParams: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: QUEUE_URL_TEST.values().next().value, + VisibilityTimeout: 10, + WaitTimeSeconds: 0 + }; + let result: ReceiveMessageCommandOutput = await receiveMessage(cleanUpParams); + while (result && Array.isArray(result.Messages) && result.Messages.length > 0) { + for (const message of result.Messages) { + await deleteMessageByHandle({ messageHandle: message.ReceiptHandle!, sqsQueueType: SqsQueueType.Communications }); + } + result = await receiveMessage(cleanUpParams); + } + }); + + async function testChangeVisibility (message: SQSMessage): Promise { + log("testChangeVisibility", LogLevel.DEBUG, { message, TEST_CHANGE_VISIBILITY }); + if (!TEST_CHANGE_VISIBILITY || !message.ReceiptHandle) { + return; + } + const changeMessageVisibilityRequest: ChangeMessageVisibilityCommandInput = { + QueueUrl: receiveParamsTest.QueueUrl, + VisibilityTimeout: receiveParamsTest.VisibilityTimeout!, + ReceiptHandle: message.ReceiptHandle + }; + try { + const start: number = Date.now(); + log("testChangeVisibility start", LogLevel.DEBUG, { start }); + // loop sleeping for 15 seconds and then locking out the message again with changeVisibility + do { + log("testChangeVisibility changeMessageVisibility", LogLevel.DEBUG, changeMessageVisibilityRequest); + await changeMessageVisibility(changeMessageVisibilityRequest); + await sleep(CHANGE_VISIBILITY_SLEEP); + } while (Date.now() - start < CHANGE_VISIBILITY_WAIT); + log("testChangeVisibility end", LogLevel.DEBUG, { start: new Date(start), end: new Date() }); + // After x minutes, make sure we can't "get" the message + const result: ReceiveMessageCommandOutput = await receiveMessage({ ...receiveParamsTest, WaitTimeSeconds: 3 }); + log("testChangeVisibility result", LogLevel.DEBUG, result); + if (result.Messages && result.Messages.length > 0) { + const duplicateMessage = result.Messages[0]; + log("changeMessageVisibility didn't lock out", LogLevel.WARN, { message, duplicateMessage, result }); + if (duplicateMessage.ReceiptHandle) { + deleteMessageByHandle({ messageHandle: duplicateMessage.ReceiptHandle, sqsQueueType: SqsQueueType.Test }) + .catch((error) => log("Could not delete duplicate message", LogLevel.ERROR, error)); + } + throw new Error("changeMessageVisibility didn't lock out"); + } + } catch (error) { + log(`Error testing changeMessageVisibility for ${message.ReceiptHandle}: ${error}`, LogLevel.ERROR, error); + throw error; + } + } + + async function testChangeVisibilityByHandle (message: SQSMessage): Promise { + log("testChangeVisibilityByHandle", LogLevel.DEBUG, { message, TEST_CHANGE_VISIBILITY }); + if (!TEST_CHANGE_VISIBILITY || !message.ReceiptHandle) { + return; + } + try { + const start: number = Date.now(); + log("testChangeVisibilityByHandle start", LogLevel.DEBUG, { start }); + // loop sleeping for 15 seconds and then locking out the message again with changeVisibility + do { + log("testChangeVisibilityByHandle changeMessageVisibilityByHandle", LogLevel.DEBUG); + await changeMessageVisibilityByHandle({ messageHandle: message.ReceiptHandle, sqsQueueType: SqsQueueType.Test }); + await sleep(CHANGE_VISIBILITY_SLEEP); + } while (Date.now() - start < CHANGE_VISIBILITY_WAIT); + log("testChangeVisibilityByHandle end", LogLevel.DEBUG, { start: new Date(start), end: new Date() }); + // After x minutes, make sure we can't "get" the message + const result: ReceiveMessageCommandOutput = await receiveMessage({ ...receiveParamsTest, WaitTimeSeconds: 3 }); + log("testChangeVisibilityByHandle result", LogLevel.DEBUG, result); + if (result.Messages && result.Messages.length > 0) { + const duplicateMessage = result.Messages[0]; + log("changeMessageVisibilityByHandle didn't lock out", LogLevel.WARN, { message, duplicateMessage, result }); + if (duplicateMessage.ReceiptHandle) { + deleteMessageByHandle({ messageHandle: duplicateMessage.ReceiptHandle, sqsQueueType: SqsQueueType.Test }) + .catch((error) => log("Could not delete duplicate message", LogLevel.ERROR, error)); + } + throw new Error("changeMessageVisibilityByHandle didn't lock out"); + } + } catch (error) { + log(`Error testing changeMessageVisibilityByHandle for ${message.ReceiptHandle}: ${error}`, LogLevel.ERROR, error); + throw error; + } + } + + it("SendMessage should succeed", (done: Mocha.Done) => { + const realSendParams: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: "Integration Test", + QueueUrl: receiveParamsTest.QueueUrl + }; + log("Send Test request", LogLevel.DEBUG, realSendParams); + // Start the receive, and while it's waiting, send the message + receiveMessage(receiveParamsTest).then((result: ReceiveMessageCommandOutput) => { + log(`receiveMessage result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + expect(result.Messages, "result.Messages").to.not.equal(undefined); + expect(result.Messages!.length, "result.Messages.length").to.equal(1); + if (result && result.Messages && result.Messages.length > 0) { + const message = result.Messages[0]; + expect(message.MessageAttributes, "message.MessageAttributes").to.not.equal(undefined); + expect(Object.keys(message.MessageAttributes!)).to.include("UnitTestMessage"); + if (message.ReceiptHandle) { + testChangeVisibility(message).then(() => { + const params: DeleteMessageCommandInput = { + QueueUrl: QUEUE_URL_TEST.values().next().value, + ReceiptHandle: message.ReceiptHandle! + }; + deleteMessage(params).then(() => { + log("deleteMessage Success", LogLevel.DEBUG); + done(); + }).catch((error) => { + log("deleteMessage Error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("testChangeVisibility Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + } else { + done(new Error("Did not receive message")); + } + }).catch((error) => { + log("receiveMessage", LogLevel.ERROR, error); + done(error); + }); + // This send is asynchronous from the receive above + sendMessage(realSendParams) + .then((result: SendMessageCommandOutput) => { + log("sendMessage Success: " + result.MessageId, LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.MessageId).to.not.equal(undefined); + }) + .catch((error) => { + log("sendMessage Error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("sendNewTestToRun should succeed", (done: Mocha.Done) => { + log("Send Test attributes", LogLevel.DEBUG, messageAttributes); + // Start the receive, and while it's waiting, send the message + getNewTestToRun().then((message: SQSMessage | undefined) => { + log(`getNewTestToRun result = ${JSON.stringify(message)}`, LogLevel.DEBUG); + expect(message, "message").to.not.equal(undefined); + expect(message!.MessageAttributes, "message.MessageAttributes").to.not.equal(undefined); + expect(Object.keys(message!.MessageAttributes!)).to.include("UnitTestMessage"); + if (message && message.ReceiptHandle) { + testChangeVisibilityByHandle(message).then(() => { + deleteMessageByHandle({ messageHandle: message.ReceiptHandle!, sqsQueueType: SqsQueueType.Test }).then(() => { + log("deleteMessageByHandle Success", LogLevel.DEBUG); + done(); + }).catch((error) => { + log("deleteMessageByHandle Error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("changeMessageVisibilityByHandle Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + }).catch((error) => { + done(error); + }); + // This send is asynchronous from the receive above + sendNewTestToRun(messageAttributes, expectedQueueUrlTestName) + .then((messageId: string | undefined) => { + log("Send Test Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + }).catch((err) => { + log("Send Test Error", LogLevel.ERROR, err); + done(err); + }); + }); + }); + + describe("Send to Real SQS Scale Queue", () => { + async function validateAndCleanupQueue (sizeExpected: number): Promise { + try { + await sleep(1000); + // getQueueAttributesMap was unreliable to get the size. Changed the clean-up to return a count. + const sizeAfter: number = await cleanUpQueue(SqsQueueType.Scale); + expect(sizeAfter, "sizeAfter").to.equal(sizeExpected); + } catch (error) { + log("validateQueue: Error getting the size of the scaling queue", LogLevel.ERROR, error); + throw error; + } + } + + beforeEach(async () => { + await cleanUpQueue(SqsQueueType.Scale); + }); + + after(async () => { + await cleanUpQueue(SqsQueueType.Scale); + }); + + it("sendTestScalingMessage should succeed", (done: Mocha.Done) => { + // Start the receive, and while it's waiting, send the message + getTestScalingMessage().then((message: SQSMessage | undefined) => { + log(`getTestScalingMessage result = ${JSON.stringify(message)}`, LogLevel.DEBUG); + // As long as we don't throw, it passes + if (message && message.ReceiptHandle && message.MessageAttributes && Object.keys(message.MessageAttributes).includes("Scale")) { + deleteMessageByHandle({ messageHandle: message.ReceiptHandle, sqsQueueType: SqsQueueType.Scale }).then(() => { + log("deleteMessageByHandle Success", LogLevel.DEBUG); + done(); + }) + .catch((error) => { + log("deleteMessageByHandle Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + }).catch((error) => { + log("getTestScalingMessage Error", LogLevel.ERROR, error); + done(error); + }); + // This send is asynchronous from the receive above + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + + it("refreshTestScalingMessage should succeed if empty", (done: Mocha.Done) => { + refreshTestScalingMessage().then((result: string | undefined) => { + log(`refreshTestScalingMessage result = ${result}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + validateAndCleanupQueue(1).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("refreshTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("refreshTestScalingMessage should succeed if has 1", (done: Mocha.Done) => { + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + refreshTestScalingMessage().then((result: string | undefined) => { + log(`refreshTestScalingMessage result = ${result}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + validateAndCleanupQueue(1).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("refreshTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + + it("refreshTestScalingMessage should succeed if has 2", (done: Mocha.Done) => { + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + sendTestScalingMessage() + .then((messageId2: string | undefined) => { + log("Send Scale Success: " + messageId2, LogLevel.DEBUG, messageId2); + expect(messageId2).to.not.equal(undefined); + refreshTestScalingMessage().then((result: string | undefined) => { + log(`refreshTestScalingMessage result = ${result}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + validateAndCleanupQueue(2).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("refreshTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + + it("deleteTestScalingMessage should succeed if empty", (done: Mocha.Done) => { + deleteTestScalingMessage().then((dmessageId: string | undefined) => { + expect(dmessageId, "dmessageId").to.equal(undefined); + validateAndCleanupQueue(0).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("deleteTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("deleteTestScalingMessage should succeed if has 1", (done: Mocha.Done) => { + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + deleteTestScalingMessage().then((dmessageId: string | undefined) => { + expect(dmessageId, "dmessageId").to.not.equal(undefined); + validateAndCleanupQueue(0).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("deleteTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + + it("deleteTestScalingMessage should succeed if has 2", (done: Mocha.Done) => { + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + sendTestScalingMessage() + .then((messageId2: string | undefined) => { + log("Send Scale Success2: " + messageId2, LogLevel.DEBUG, messageId2); + expect(messageId2).to.not.equal(undefined); + deleteTestScalingMessage().then((dmessageId: string | undefined) => { + expect(dmessageId, "dmessageId").to.not.equal(undefined); + validateAndCleanupQueue(1).then(() => done()) + .catch((error) => { + log("validateQueue error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("deleteTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + }); + }); + + describe("SQS getQueueAttributes", () => { + it("should getQueueAttributes with test params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_TEST.values().next().value, + AttributeNames: ["All"] + }; + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributes with scale params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_SCALE_IN.values().next().value, + AttributeNames: ["All"] + }; + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributes with communications params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_COMMUNICATION, + AttributeNames: ["All"] + }; + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with test params", (done: Mocha.Done) => { + getQueueAttributesMap(SqsQueueType.Test, expectedQueueUrlTestName).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with scale params", (done: Mocha.Done) => { + getQueueAttributesMap(SqsQueueType.Scale, expectedQueueUrlScaleName).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with communications params", (done: Mocha.Done) => { + getQueueAttributesMap(SqsQueueType.Communications).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + }); +}); diff --git a/common/package.json b/common/package.json new file mode 100644 index 00000000..832b6c36 --- /dev/null +++ b/common/package.json @@ -0,0 +1,58 @@ +{ + "name": "@fs/ppaas-common", + "version": "3.0.0", + "description": "Common Code for the PewPewController and PewPewAgent", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "files": [ + "dist/src/**/*", + "dist/types/**/*" + ], + "scripts": { + "build": "npm run buildonly", + "buildonly": "tsc", + "test": "npm run buildonly && nyc mocha ./dist/test --timeout 30000 -r dotenv-flow/config", + "testonly": "nyc mocha ./dist/test --timeout 30000 -r dotenv-flow/config", + "integration": "npm run build && nyc mocha ./dist/integration --timeout 300000 -r dotenv-flow/config", + "coverage": "npm run build && nyc mocha ./dist/test ./dist/integration --timeout 300000 -r dotenv-flow/config", + "prepare": "npm run buildonly", + "clean": "rimraf dist/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/FamilySearch/pewpew.git" + }, + "engines": { + "node": ">=18.0.0 <21.0.0" + }, + "nyc": { + "exclude": "**/*.spec.ts" + }, + "dependencies": { + "@aws-sdk/client-ec2": "^3.363.0", + "@aws-sdk/client-s3": "^3.363.0", + "@aws-sdk/client-sqs": "^3.363.0", + "@aws-sdk/lib-storage": "^3.363.0", + "@fs/config-wasm": "*", + "bunyan": "~1.8.0", + "dotenv": "^16.0.0", + "dotenv-flow": "^3.2.0", + "rimraf": "^5.0.0" + }, + "devDependencies": { + "@aws-sdk/util-stream-node": "^3.363.0", + "@types/bunyan": "~1.8.8", + "@types/chai": "^4.3.5", + "@types/express": "^4.17.17", + "@types/mocha": "^10.0.0", + "@types/node": "^20.0.0", + "@typescript-eslint/eslint-plugin": "^6.0.0", + "@typescript-eslint/parser": "^6.0.0", + "aws-sdk-client-mock": "^3.0.0", + "chai": "^4.3.7", + "eslint": "^8.40.0", + "mocha": "^10.2.0", + "nyc": "^15.1.0", + "typescript": "~5.2.0" + } +} diff --git a/common/setup.js b/common/setup.js new file mode 100644 index 00000000..6a35339a --- /dev/null +++ b/common/setup.js @@ -0,0 +1,4 @@ +"use strict"; +fs = require("fs"); +fs.createReadStream(".sample-env") + .pipe(fs.createWriteStream(".env.local")); \ No newline at end of file diff --git a/common/src/index.ts b/common/src/index.ts new file mode 100644 index 00000000..e4f687e3 --- /dev/null +++ b/common/src/index.ts @@ -0,0 +1,39 @@ +import * as ec2 from "./util/ec2"; +import * as logger from "./util/log"; +import * as s3 from "./util/s3"; +import * as sqs from "./util/sqs"; +import * as util from "./util/util"; +import { LogLevel, log } from "./util/log"; +import { MakeTestIdOptions, PpaasTestId } from "./ppaastestid"; +import { PpaasS3File, PpaasS3FileCopyOptions, PpaasS3FileOptions } from "./s3file"; +import { PpaasS3Message, PpaasS3MessageOptions } from "./ppaass3message"; +import { PpaasCommunicationsMessage } from "./ppaascommessage"; +import { PpaasTestMessage } from "./ppaastestmessage"; +import { PpaasTestStatus } from "./ppaasteststatus"; +import { YamlParser } from "./yamlparser"; + +export * from "../types"; + +export type { + PpaasS3MessageOptions, + MakeTestIdOptions, + PpaasS3FileOptions, + PpaasS3FileCopyOptions +}; + +export { + ec2, + logger, + s3, + sqs, + util, + log, + LogLevel, + PpaasCommunicationsMessage, + PpaasS3Message, + PpaasTestId, + PpaasTestStatus, + PpaasTestMessage, + PpaasS3File, + YamlParser +}; diff --git a/common/src/ppaascommessage.ts b/common/src/ppaascommessage.ts new file mode 100644 index 00000000..9cba6c1f --- /dev/null +++ b/common/src/ppaascommessage.ts @@ -0,0 +1,198 @@ +import { CommunicationsMessage, MessageType, SqsQueueType } from "../types"; +import { LogLevel, log } from "./util/log"; +import { MessageAttributeValue, Message as SQSMessage } from "@aws-sdk/client-sqs"; +import { deleteMessageByHandle, getCommunicationMessage, sendNewCommunicationsMessage } from "./util/sqs"; + +/** Messages from the agent to the controller */ +export class PpaasCommunicationsMessage implements CommunicationsMessage { + public testId: string; + public messageType: MessageType; + public messageData: any; + public receiptHandle: string | undefined; + // Protected member so only unit tests that extend this class can set it. + protected unittestMessage: boolean | undefined; + + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + public constructor ({ testId, + messageType, + messageData + }: Partial) { + if (!testId || !messageType) { + // Don't log the messageData + log("PpaasCommunicationsMessage was missing data", LogLevel.ERROR, { testId, messageType }); + throw new Error("PpaasCommunicationsMessage was missing testId, sender, recipient, or message"); + } + this.testId = testId; + this.messageType = messageType; + this.messageData = messageData; + } + + public getCommunicationsMessage (): CommunicationsMessage { + return { + testId: this.testId, + messageType: this.messageType, + messageData: this.messageData + }; + } + + // Create a sanitized copy which doesn't have the messageData which may have passwords + public sanitizedCopy (): CommunicationsMessage & { receiptHandle: string | undefined } { + const returnObject: CommunicationsMessage & { receiptHandle: string | undefined } = { + ...this.getCommunicationsMessage(), + messageData: undefined, + receiptHandle: this.receiptHandle + }; + return JSON.parse(JSON.stringify(returnObject)); + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + protected static async parseSqsMessage (sqsMessage: SQSMessage): Promise { + if (!sqsMessage.MessageAttributes) { + log("SQSMessage.MessageAttributes cannot be null.", LogLevel.ERROR, sqsMessage); + throw new Error("SQSMessage.MessageAttributes cannot be null."); + } + const messageAttributes: Record = sqsMessage.MessageAttributes; + let testId: string | undefined; + let messageType: MessageType | undefined; + let messageData: any; + const receiptHandle: string = sqsMessage.ReceiptHandle!; + let unittestMessage : boolean = false; + // Go through all the message attributes and parse them + for (const [key, value] of Object.entries(messageAttributes)) { + if (value.DataType === "Binary") { + let temp: any; + try { + // It's a JSON object stored as a buffer + temp = JSON.parse(Buffer.from(value.BinaryValue!).toString()); + log(`messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`, LogLevel.DEBUG, temp); + } catch (error: unknown) { + log(`messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`, LogLevel.ERROR, error); + throw new Error(`New Communications Message Attribute could not be parsed: messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`); + } + switch (key) { + case "MessageData": + // It can only be in there once either as Binary or String so we can overwrite and don't need to append + messageData = temp; + break; + default: + log(`New Communications Message Attribute was not a known Binary messageAttribute: messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.ERROR, { key, value }); + break; + } + continue; + } else if (value.DataType === "String") { // "Number" also is stored as StringValue + log(`messageAttributes[${key}].StringValue = ${value.StringValue}`, LogLevel.DEBUG); + switch(key) { + // If this is set, it isn't a real message and should be swallowed. It was from an integration test + case "UnitTestMessage": + unittestMessage = true; + break; + case "TestId": + testId = value.StringValue; + break; + case "MessageType": + try { + messageType = MessageType[(value.StringValue || "") as keyof typeof MessageType]; + } catch (error: unknown) { + log(`New Communications Message Attribute 'MessageType' not be parsed: messageAttributes[${key}].StringValue = ${value.StringValue}`, LogLevel.ERROR, error); + throw new Error(`New Communications Message Attribute 'MessageType' not be parsed: messageAttributes[${key}].StringValue = ${value.StringValue}, error: ${error}`); + } + break; + case "MessageData": + // It can only be in there once either as Binary or String so we can overwrite and don't need to append + messageData = value.StringValue; + break; + default: + log(`New Communications Message Attribute was not a known String messageAttribute: messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.ERROR, { key, value }); + break; + } + } else { + log(`messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.ERROR); + throw new Error(`New Communications Message Attribute was not type String or Binary: messageAttributes[${key}].DataType = ${value.DataType}`); + } + } + // Is it an integration message we shouldn't swallow + if (unittestMessage) { + const unitTestMessage: PpaasCommunicationsMessage = new PpaasCommunicationsMessage({ testId, messageType, messageData }); + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + unitTestMessage.receiptHandle = receiptHandle; + log(`New Integration communications message received at ${Date.now()}: ${testId}`, LogLevel.INFO, unitTestMessage.sanitizedCopy()); + log(`Removing Integration Test Communications Message from queue ${receiptHandle}`, LogLevel.INFO); + // Only delete it if it's a test message + await deleteMessageByHandle({ messageHandle: receiptHandle, sqsQueueType: SqsQueueType.Communications }) + .catch((error) => log(`Could not remove Integration Test message from from queue: ${receiptHandle}`, LogLevel.ERROR, error)); + return undefined; + } + const newMessage: PpaasCommunicationsMessage = new PpaasCommunicationsMessage({ testId, messageType, messageData }); + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + newMessage.receiptHandle = receiptHandle; + return newMessage; + } + + // Only used by the controller + public static async getMessage (): Promise { + const message: SQSMessage | undefined = await getCommunicationMessage(); + if (message) { + log("We found a message for the controller", LogLevel.DEBUG, message); + const newMessage: PpaasCommunicationsMessage | undefined = await this.parseSqsMessage(message); + // If it was a unit test message, we'll get undefined and should keep going. Otherwise return it + if (newMessage) { return newMessage; } + } + return undefined; + } + + public async send (): Promise { + // Send the SQS Message + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: this.testId + }, + MessageType: { + DataType: "String", + StringValue: MessageType[this.messageType] + } + }; + if (this.messageData) { + if (typeof this.messageData === "string") { + messageAttributes["MessageData"] = { + DataType: "String", + StringValue: this.messageData + }; + } else if (this.messageData instanceof Map || this.messageData instanceof Set) { + messageAttributes["MessageData"] = { + DataType: "Binary", + BinaryValue: Buffer.from(JSON.stringify([...this.messageData])) // Need to cast it to an array + }; + } else { + messageAttributes["MessageData"] = { + DataType: "Binary", + BinaryValue: Buffer.from(JSON.stringify(this.messageData)) + }; + } + } + // Protected member so only unit tests that extend this class can set it. + if (this.unittestMessage) { + messageAttributes["UnitTestMessage"] = { + DataType: "String", + StringValue: "true" + }; + } + log("PpaasCommunicationsMessage.send messageAttributes", LogLevel.DEBUG, Object.assign({}, messageAttributes, { MessageData: undefined })); + const messageId: string | undefined = await sendNewCommunicationsMessage(messageAttributes); + log(`PpaasCommunicationsMessage.send messageId: ${messageId}`, LogLevel.INFO, this.sanitizedCopy()); + return messageId; + } + + public async deleteMessageFromQueue (): Promise { + if (this.receiptHandle) { + await deleteMessageByHandle({ messageHandle: this.receiptHandle, sqsQueueType: SqsQueueType.Communications }); + this.receiptHandle = undefined; + } + } +} + +export default PpaasCommunicationsMessage; diff --git a/common/src/ppaass3message.ts b/common/src/ppaass3message.ts new file mode 100644 index 00000000..117816cc --- /dev/null +++ b/common/src/ppaass3message.ts @@ -0,0 +1,156 @@ +import { CommunicationsMessage, MessageType } from "../types"; +import { LogLevel, log } from "./util/log"; +import { + defaultTestFileTags, + deleteObject, + getFileContents, + init as initS3, + listFiles, + uploadFileContents +} from "./util/s3"; +import { PpaasTestId } from "./ppaastestid"; + +// Due to issues with visibility 0 and multiple lockouts, The communications queue is only for talking to the controller +// Going to the agent will write to a file in the s3Folder +export const createS3Filename = (ppaasTestId: PpaasTestId) => `${ppaasTestId.testId}.msg`; + +export const getKey = (ppaasTestId: PpaasTestId) => `${ppaasTestId.s3Folder}/${createS3Filename(ppaasTestId)}`; + +export interface PpaasS3MessageOptions extends Omit { + testId: string | PpaasTestId +} + +/** Messages from the controller to the agent */ +export class PpaasS3Message implements CommunicationsMessage { + public testId: string; + public messageType: MessageType; + public messageData: any; + protected ppaasTestId: PpaasTestId; + protected inS3: boolean = false; + + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + public constructor ({ + testId, + messageType, + messageData + }: PpaasS3MessageOptions) { + try { + initS3(); + } catch (error: unknown) { + log("Could not initialize s3", LogLevel.ERROR, error); + throw error; + } + if (typeof testId === "string") { + try { + this.ppaasTestId = PpaasTestId.getFromTestId(testId); + this.testId = this.ppaasTestId.testId; + } catch (error: unknown) { + log("Could not initialize s3", LogLevel.ERROR, error); + throw error; + } + } else { + this.testId = testId.testId; + this.ppaasTestId = testId; + } + this.messageType = messageType; + this.messageData = messageData; + } + + public getCommunicationsMessage (): CommunicationsMessage { + return { + testId: this.testId, + messageType: this.messageType, + messageData: this.messageData + }; + } + + // Create a sanitized copy which doesn't have the messageData which may have passwords + public sanitizedCopy (): CommunicationsMessage & { inS3: boolean } { + const returnObject: CommunicationsMessage & { inS3: boolean } = { + ...this.getCommunicationsMessage(), + messageData: undefined, + inS3: this.inS3 + }; + return JSON.parse(JSON.stringify(returnObject)); + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + // Only used by the agents + public static async getMessage (ppaasTestId: PpaasTestId): Promise { + const key = getKey(ppaasTestId); + try { + const s3Filename = createS3Filename(ppaasTestId); + const s3Files = await listFiles({ s3Folder: key, maxKeys: 1 }); + log(`listFiles(${key}, 1).length = ${s3Files.length}`, LogLevel.DEBUG); + if (s3Files.length === 0) { + return undefined; + } + const contents: string | undefined = await getFileContents({ filename: s3Filename, s3Folder: ppaasTestId.s3Folder }); + log(`getFileContents(${s3Filename}, ${ppaasTestId.s3Folder})`, LogLevel.DEBUG, { contents }); + if (!contents) { + // File exists but is empty, delete it + deleteObject(key).catch((error) => log(`Could not delete ${key}`, LogLevel.ERROR, error)); + return undefined; + } + log("We found a message for s3Folder " + ppaasTestId.s3Folder, LogLevel.DEBUG, { contents }); + try { + const communicationsMessage: Partial = JSON.parse(contents); + if (!communicationsMessage || communicationsMessage.messageType === undefined) { + log("PpaasS3Message.getMessage found invalid message for key: " + key, LogLevel.WARN, communicationsMessage); + return undefined; + } + log(`getFileContents(${s3Filename}, ${s3Filename})`, LogLevel.DEBUG, { message: communicationsMessage }); + const newMessage = new PpaasS3Message({ + ...(communicationsMessage as CommunicationsMessage), + testId: ppaasTestId + }); + newMessage.inS3 = true; // Set this so we can delete it later + return newMessage; + } catch (error: unknown) { + log(`Could not parse ${getKey(ppaasTestId)} contents: ` + contents, LogLevel.ERROR, error); + throw error; + } + } catch (error: unknown) { + log(`getMessage(${ppaasTestId.s3Folder}) ERROR`, LogLevel.ERROR, error); + throw error; + } + } + + public async send (): Promise { + // Send the S3 Message + const communicationsMessage: CommunicationsMessage = { + testId: this.testId, + messageType: this.messageType, + messageData: this.messageData + }; + if (this.messageData instanceof Map || this.messageData instanceof Set) { + communicationsMessage.messageData = [...this.messageData]; // Need to cast it to an array + } + + log("Sending new communications message to s3", LogLevel.DEBUG, this.sanitizedCopy()); + const url = await uploadFileContents({ + contents: JSON.stringify(communicationsMessage), + filename: createS3Filename(this.ppaasTestId), + s3Folder: this.ppaasTestId.s3Folder, + publicRead: false, + contentType: "application/json", + tags: defaultTestFileTags() + }); + this.inS3 = true; + log(`PpaasS3Message.send url: ${url}`, LogLevel.INFO, this.sanitizedCopy()); + return url; + } + + public async deleteMessageFromS3 (): Promise { + if (this.inS3) { + await deleteObject(getKey(this.ppaasTestId)); + this.inS3 = false; + } + } +} + +export default PpaasS3Message; diff --git a/common/src/ppaastestid.ts b/common/src/ppaastestid.ts new file mode 100644 index 00000000..f35dc6a1 --- /dev/null +++ b/common/src/ppaastestid.ts @@ -0,0 +1,79 @@ +import path from "path"; + +export interface MakeTestIdOptions { + profile?: string; + dateString?: string; +} + +export class PpaasTestId { + public readonly date: Date; + public readonly dateString: string; + public readonly yamlFile: string; + public readonly testId: string; + public readonly s3Folder: string; + + protected constructor (yamlFile: string, dateString: string) { + if (!yamlFile || yamlFile.length === 0) { + throw new Error("Invalid Yamlfile: " + yamlFile); + } + if (!dateString || !/^\d{8}T\d{9}$/.test(dateString)) { + throw new Error("Invalid dateString does not match expected: " + dateString); + } + // 20190101T000000000 -> 2019-01-01T00:00:00.000Z + const dateIsoString = `${dateString.slice(0,4)}-${dateString.slice(4,6)}-${dateString.slice(6,8)}T` + + `${dateString.slice(9,11)}:${dateString.slice(11,13)}:${dateString.slice(13,15)}.${dateString.slice(15,18)}Z`; + const date = new Date(dateIsoString); + if (date.toISOString() !== dateIsoString) { + throw new Error(`Could not parse ${dateString} to a date ISO string (${dateIsoString}) that would parse: ${date.toISOString()}`); + } + this.date = date; + this.dateString = dateString; + this.yamlFile = yamlFile; + this.testId = yamlFile + dateString; + this.s3Folder = yamlFile + "/" + dateString; + } + + public static getDateString (date: Date = new Date()) { + // eslint-disable-next-line no-useless-escape + return date.toISOString().replace(/[-:\.Z]/g, ""); + } + + public static getFromTestId (testId: string): PpaasTestId { + const match: RegExpMatchArray | null = testId.match(/^(.+)(\d{8}T\d{9})$/); + if (match && match.length === 3) { + const yamlname: string = match[1]; + const dateString = match[2]; + const newTestId: PpaasTestId = new PpaasTestId(yamlname, dateString); + return newTestId; + } else { + throw new Error(`Could not parse ${testId} into a TestId`); + } + } + + public static getFromS3Folder (s3Folder: string): PpaasTestId { + const match: RegExpMatchArray | null = s3Folder.match(/^(.+)\/(\d{8}T\d{9})$/); + if (match && match.length === 3) { + const yamlname: string = match[1]; + const dateString = match[2]; + const newTestId: PpaasTestId = new PpaasTestId(yamlname, dateString); + return newTestId; + } else { + throw new Error(`Could not parse ${s3Folder} into a TestId`); + } + } + + public static makeTestId (yamlFile: string, options?: MakeTestIdOptions): PpaasTestId { + const { profile, dateString } = options || {}; + // Sanitize the yamlFile name and make it lowercase + const yamlname: string = (path.basename(yamlFile, path.extname(yamlFile)).toLocaleLowerCase() + + (profile || "").toLocaleLowerCase()) + .replace(/[^a-z0-9]/g, ""); + if (yamlname === "pewpew") { + throw new Error("Yaml File cannot be named PewPew"); + } + const newTestId: PpaasTestId = new PpaasTestId(yamlname, dateString || this.getDateString()); + return newTestId; + } +} + +export default PpaasTestId; diff --git a/common/src/ppaastestmessage.ts b/common/src/ppaastestmessage.ts new file mode 100644 index 00000000..e3a9829c --- /dev/null +++ b/common/src/ppaastestmessage.ts @@ -0,0 +1,247 @@ +import { AgentQueueDescription, EnvironmentVariables, SqsQueueType, TestMessage } from "../types"; +import { LogLevel, log } from "./util/log"; +import { MessageAttributeValue, Message as SQSMessage } from "@aws-sdk/client-sqs"; +import { + QUEUE_URL_TEST, + changeMessageVisibilityByHandle, + deleteMessageByHandle, + sendNewTestToRun, + getNewTestToRun as sqsGetNewTestToRun, + init as sqsInit +} from "./util/sqs"; + +const DEFAULT_BUCKET_SIZE: number = parseInt(process.env.DEFAULT_BUCKET_SIZE || "0", 10) || 60000; +// If we're in a AWS, the default should be empty, we need the real list. If we're running locally, default it to a single size +const AgentDescriptions: string[] = (process.env.AGENT_DESC || "unknown").split(","); +export class PpaasTestMessage implements TestMessage { + public testId: string; + public s3Folder: string; + public yamlFile: string; + public additionalFiles: string[] | undefined; + public testRunTimeMn: number | undefined; + public bucketSizeMs: number; + public version: string; + public envVariables: EnvironmentVariables; + public userId: string | undefined; + public restartOnFailure: boolean; + public bypassParser: boolean | undefined; + public receiptHandle: string | undefined; + public messageId: string | undefined; + // Protected member so only unit tests that extend this class can set it. + protected unittestMessage: boolean | undefined; + protected static readonly AgentQueueDescriptionMapping: AgentQueueDescription = {}; + + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + public constructor ({ + testId, + s3Folder, + yamlFile, + additionalFiles, + testRunTimeMn, + bucketSizeMs, + version, + envVariables, + userId, + restartOnFailure, + bypassParser + }: TestMessage) { + this.testId = testId; + this.s3Folder = s3Folder; + this.yamlFile = yamlFile; + this.additionalFiles = additionalFiles; + this.testRunTimeMn = testRunTimeMn; + this.bucketSizeMs = bucketSizeMs || DEFAULT_BUCKET_SIZE; + this.version = version; + this.envVariables = envVariables; + this.userId = userId; + this.restartOnFailure = restartOnFailure; + this.bypassParser = bypassParser; + // Remove any invalid file characters from testId, or just allow letters, numbers, and dash/underscore + this.testId = this.testId.replace(/[^\w\d-_]/g, ""); + } + + /** Gets the TestMessage data as an object */ + public getTestMessage (): TestMessage { + return { + testId: this.testId, + s3Folder: this.s3Folder, + yamlFile: this.yamlFile, + additionalFiles: this.additionalFiles, + testRunTimeMn: this.testRunTimeMn, + bucketSizeMs: this.bucketSizeMs, + version: this.version, + envVariables: this.envVariables, + userId: this.userId, + restartOnFailure: this.restartOnFailure, + bypassParser: this.bypassParser + }; + } + + /** Create a sanitized copy which doesn't have the environment variable values which may have passwords */ + public sanitizedCopy (): Omit & { envVariables: string[] } { + const returnObject: Omit & { envVariables: string[] } = { + ...this.getTestMessage(), + envVariables: Object.keys(this.envVariables) + }; + return JSON.parse(JSON.stringify(returnObject)); + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + public static getAvailableQueueNames (): string[] { + sqsInit(); + return Array.from(QUEUE_URL_TEST.keys()); + } + + public static getAvailableQueueMap (): AgentQueueDescription { + if (Object.keys(PpaasTestMessage.AgentQueueDescriptionMapping).length > 0) { return PpaasTestMessage.AgentQueueDescriptionMapping; } + const queueNames = PpaasTestMessage.getAvailableQueueNames(); + log("Creating the AgentQueueDescriptionMap", LogLevel.DEBUG, { queueNames, AgentDescriptions }); + if (queueNames.length === AgentDescriptions.length) { + for (let i = 0; i < queueNames.length; i++) { + if (queueNames[i] && AgentDescriptions[i]) { + PpaasTestMessage.AgentQueueDescriptionMapping[queueNames[i]] = AgentDescriptions[i]; + } + } + log("AgentQueueDescriptionMap", LogLevel.DEBUG, PpaasTestMessage.AgentQueueDescriptionMapping); + } else { + log("Cannot create the AgentQueueDescriptionMap, queueNames and AgentDescriptions don't match in length", LogLevel.ERROR, { queueNames, AgentDescriptions }); + throw new Error("Cannot create the AgentQueueDescriptionMap, queueNames and AgentDescriptions don't match in length"); + } + return PpaasTestMessage.AgentQueueDescriptionMapping; + } + + public static async getNewTestToRun (): Promise { + const sqsMessage: SQSMessage | undefined = await sqsGetNewTestToRun(); + if (!sqsMessage || !sqsMessage.MessageAttributes) { + return undefined; + } + const messageAttributes: Record = sqsMessage.MessageAttributes; + let testId: string | undefined; + const receiptHandle: string = sqsMessage.ReceiptHandle!; + let unittestMessage: boolean = false; + let parsedTestMessage: TestMessage | undefined; + // Go through all the message attributes and parse them + for (const [key, value] of Object.entries(messageAttributes)) { + if (value.DataType === "Binary") { + let temp: any; + try { + // It's a JSON object stored as a buffer + temp = JSON.parse(Buffer.from(value.BinaryValue!).toString()); + log(`messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`, LogLevel.DEBUG, temp); + } catch (error: unknown) { + log(`messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`, LogLevel.ERROR, error); + throw new Error(`New Test Message Attribute could not be parsed: messageAttributes[${key}].BinaryValue = ${value.BinaryValue}`); + } + switch (key) { + case "TestMessage": + try { + parsedTestMessage = temp as TestMessage; + } catch (error: unknown) { + throw new Error(`messageAttributes[${key}] was not an TestMessage = ${JSON.stringify(temp)}`); + } + break; + default: + log(`New Test Message Attribute was not a known Binary messageAttribute: messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.WARN, { key, value }); + break; + } + continue; + } else if (value.DataType === "String") { + log(`messageAttributes[${key}].StringValue = ${value.StringValue}`, LogLevel.DEBUG); + switch(key) { + // If this is set, it isn't a real message and should be swallowed. It was from an integration test + case "UnitTestMessage": + unittestMessage = true; + break; + case "TestId": + // TODO: Should we vaildate this is just a file path and not an exploit since it's passed on the command line? + testId = value.StringValue; + break; + default: // Environment variable + log(`New Test Message Attribute was not a known String messageAttribute: messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.WARN, { key, value }); + break; + } + } else { + log(`messageAttributes[${key}].DataType = ${value.DataType}`, LogLevel.ERROR); + throw new Error(`New Test Message Attribute was not type String or Binary: messageAttributes[${key}].DataType = ${value.DataType}`); + } + } + if (!parsedTestMessage) { + log("PpaasTestMessage was missing the TestMessage", LogLevel.ERROR, { testId }); + throw new Error("New Test Message was missing testId, s3Folder, yamlFile, or testRunTime"); + } else if (!parsedTestMessage.testId || typeof parsedTestMessage.testId !== "string" + || !parsedTestMessage.s3Folder || typeof parsedTestMessage.s3Folder !== "string" + || !parsedTestMessage.yamlFile || typeof parsedTestMessage.yamlFile !== "string" + || !parsedTestMessage.version || typeof parsedTestMessage.version !== "string" + || !parsedTestMessage.envVariables || typeof parsedTestMessage.envVariables !== "object" + ) { + // Don't log the environment variables + log("PpaasTestMessage was missing data", LogLevel.ERROR, { ...parsedTestMessage, envVariables: Object.keys(parsedTestMessage.envVariables) }); + throw new Error("New Test Message was missing testId, s3Folder, yamlFile, or envVariables"); + } + const newTest: PpaasTestMessage = new this(parsedTestMessage); + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + newTest.receiptHandle = receiptHandle; + // Is it an integration message we shouldn't run + if (unittestMessage) { + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + log(`New Integration TestMessage received at ${Date.now()}: ${testId}`, LogLevel.INFO, newTest.sanitizedCopy()); + log(`Removing Integration TestMessage from queue ${receiptHandle}`, LogLevel.INFO); + // Only delete it if it's a test message + await deleteMessageByHandle({ messageHandle: receiptHandle, sqsQueueType: SqsQueueType.Test }) + .catch((error) => log(`Could not remove Integration Test message from from queue: ${receiptHandle}`, LogLevel.ERROR, error)); + return undefined; + } + // The sanitizedCopy wipes out the envVariables (which may have passwords) from the logs + log(`New TestMessage received at ${Date.now()}: ${testId}`, LogLevel.INFO, newTest.sanitizedCopy()); + return newTest; + } + + public async send (queueName: string): Promise { + if (this.testRunTimeMn === undefined && !this.bypassParser) { + // Don't log the environment variables + log("TestMessage must either have a testRunTimeMn or have set bypassParser", LogLevel.ERROR, this.sanitizedCopy()); + throw new Error("TestMessage must either have a testRunTimeMn or have set bypassParser"); + } + const testMessage: TestMessage = this.getTestMessage(); + // Send the SQS Message + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: this.testId + }, + TestMessage: { + DataType: "Binary", + BinaryValue: Buffer.from(JSON.stringify(testMessage)) + } + }; + // Protected member so only unit tests that extend this class can set it. + if (this.unittestMessage) { + messageAttributes["UnitTestMessage"] = { + DataType: "String", + StringValue: "true" + }; + } + log("PpaasTestMessage.send messageAttributes", LogLevel.DEBUG, Object.assign({}, messageAttributes, { EnvironmentVariables: undefined })); + this.messageId = await sendNewTestToRun(messageAttributes, queueName); + log(`PpaasTestMessage.send messageId: ${this.messageId}`, LogLevel.INFO, this.sanitizedCopy()); + } + + public async extendMessageLockout (): Promise { + if (this.receiptHandle) { + await changeMessageVisibilityByHandle({ messageHandle: this.receiptHandle, sqsQueueType: SqsQueueType.Test }); + } + } + + public async deleteMessageFromQueue (): Promise { + if (this.receiptHandle) { + await deleteMessageByHandle({ messageHandle: this.receiptHandle, sqsQueueType: SqsQueueType.Test }); + this.receiptHandle = undefined; + } + } +} + +export default PpaasTestMessage; diff --git a/common/src/ppaasteststatus.ts b/common/src/ppaasteststatus.ts new file mode 100644 index 00000000..4239c603 --- /dev/null +++ b/common/src/ppaasteststatus.ts @@ -0,0 +1,258 @@ +import { LogLevel, log } from "./util/log"; +import { TestStatus, TestStatusMessage } from "../types"; +import { + defaultTestFileTags, + getFileContents as getFileContentsS3, + init as initS3, + listFiles, + uploadFileContents +} from "./util/s3"; +import PpaasTestId from "./ppaastestid"; +import { _Object as S3Object } from "@aws-sdk/client-s3"; + +export const createS3Filename = (ppaasTestId: PpaasTestId): string => `${ppaasTestId.testId}.info`; + +export const getKey = (ppaasTestId: PpaasTestId): string => `${ppaasTestId.s3Folder}/${createS3Filename(ppaasTestId)}`; + +export class PpaasTestStatus implements TestStatusMessage { + public instanceId: string | undefined; + public hostname: string | undefined; + public ipAddress: string | undefined; + public startTime: number; + public endTime: number; + public resultsFilename: string[]; + public status: TestStatus; + public errors: string[] | undefined; + public version?: string; + public queueName?: string; + public userId?: string; + protected ppaasTestId: PpaasTestId; + protected lastModifiedRemote: Date; + protected url: string | undefined; + + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + public constructor (ppaasTestId: PpaasTestId, + testStatusMessage: TestStatusMessage) { + try { + initS3(); + } catch (error: unknown) { + log("Could not initialize s3", LogLevel.ERROR, error); + throw error; + } + this.ppaasTestId = ppaasTestId; + this.instanceId = testStatusMessage.instanceId; + this.hostname = testStatusMessage.hostname; + this.ipAddress = testStatusMessage.ipAddress; + this.startTime = testStatusMessage.startTime; + this.endTime = testStatusMessage.endTime; + this.resultsFilename = testStatusMessage.resultsFilename; + this.status = testStatusMessage.status; + this.errors = testStatusMessage.errors; + this.version = testStatusMessage.version; + this.queueName = testStatusMessage.queueName; + this.userId = testStatusMessage.userId; + this.lastModifiedRemote = new Date(0); // It hasn't been downloaded yet + } + + public getTestStatusMessage (): TestStatusMessage { + const testStatus: TestStatusMessage = { + instanceId: this.instanceId, + hostname: this.hostname, + ipAddress: this.ipAddress, + startTime: this.startTime, + endTime: this.endTime, + resultsFilename: this.resultsFilename, + status: this.status, + errors: this.errors, + version: this.version, + queueName: this.queueName, + userId: this.userId + }; + return testStatus; + } + + public sanitizedCopy (): TestStatusMessage & { + testId: string, + url: string | undefined, + lastModifiedRemote: Date + } { + const returnObject: TestStatusMessage & { + testId: string, + url: string | undefined, + lastModifiedRemote: Date + } = { + ...this.getTestStatusMessage(), + testId: this.ppaasTestId.testId, + url: this.url, + lastModifiedRemote: this.lastModifiedRemote + }; + return JSON.parse(JSON.stringify(returnObject)); + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + public getLastModifiedRemote () { + return this.lastModifiedRemote; + } + + public getTestId (): string { + return this.ppaasTestId.testId; + } + + public getS3Folder (): string { + return this.ppaasTestId.s3Folder; + } + + // Get one initially + // Returns an object, false if not found, or true if unchanged + protected static async getStatusInternal (ppaasTestId: PpaasTestId, lastModified?: Date): Promise { + const key = getKey(ppaasTestId); + try { + const s3Filename = createS3Filename(ppaasTestId); + const s3Files = await listFiles({ s3Folder: key, maxKeys: 1 }); + log(`PpaasTestStatus listFiles(${key}, 1).length = ${s3Files.length}`, LogLevel.DEBUG); + if (s3Files.length === 0) { + return false; // Not found + } + const contents: string | undefined = await getFileContentsS3({ + filename: s3Filename, + s3Folder: ppaasTestId.s3Folder, + lastModified + }); + log(`PpaasTestStatus getFileContents(${s3Filename}, ${ppaasTestId.s3Folder})`, LogLevel.DEBUG, { contents }); + if (!contents) { + return true; // Unchanged since lastModified + } + log("PpaasTestStatus Status found in s3Folder " + ppaasTestId.s3Folder, LogLevel.DEBUG, { contents }); + try { + const testStatusMessage: TestStatusMessage = JSON.parse(contents); + log(`PpaasTestStatus getFileContents(${s3Filename}, ${s3Filename})`, LogLevel.DEBUG, { testStatusMessage }); + const newMessage = new PpaasTestStatus(ppaasTestId, testStatusMessage); + const s3File = s3Files[0]; + // We have to return a PpaasTestStatus instead of a TestStatusMessage so we can return the lastModifiedRemote + if (s3File.LastModified) { + newMessage.lastModifiedRemote = s3File.LastModified; + } + log(`PpaasTestStatus.getStatus(${ppaasTestId.s3Folder})`, LogLevel.DEBUG, { newMessage: newMessage.sanitizedCopy() }); + return newMessage; + } catch (error: unknown) { + log(`PpaasTestStatus Could not parse ${getKey(ppaasTestId)} contents: ` + contents, LogLevel.ERROR, error); + throw error; + } + } catch (error: unknown) { + log(`PpaasTestStatus.getMessage(${ppaasTestId.s3Folder}) ERROR`, LogLevel.ERROR, error); + throw error; + } + } + + public static async getStatus (ppaasTestId: PpaasTestId): Promise { + const updatedStatus: PpaasTestStatus | boolean = await PpaasTestStatus.getStatusInternal(ppaasTestId); + // It will never actually be "true" since we don't pass in a last modified + if (updatedStatus === true || updatedStatus === false) { + return undefined; + } else { + return updatedStatus; + } + } + + public static async getAllStatus ( + s3FolderPartial: string, + maxFiles?: number, + ignoreList?: string[] + ): Promise[] | undefined> { + const s3Files: S3Object[] = await listFiles({ s3Folder: s3FolderPartial, maxKeys: maxFiles, extension: "info" }); + if (s3Files.length > 0) { + interface TestIdContents { + ppaasTestId: PpaasTestId; + s3File: S3Object; + contents: string | undefined; + } + const ppaasTestIds: TestIdContents[] = s3Files.map((s3File: S3Object) => { + try { + if (!s3File.Key) { return undefined; } + const testId: string = s3File.Key.slice(s3File.Key.lastIndexOf("/") + 1, s3File.Key.lastIndexOf(".")); + log(`Parsed testId ${testId} from ${s3File.Key}`, LogLevel.DEBUG); + if (ignoreList && ignoreList.includes(testId)) { + return undefined; + } + const ppaasTestId: PpaasTestId = PpaasTestId.getFromTestId(testId); + log(`Parsed ppaasTestId from ${testId}`, LogLevel.DEBUG, ppaasTestId); + return { ppaasTestId, s3File, contents: undefined }; + } catch (error: unknown) { + log(`Could not parse testId from ${s3File.Key}`, LogLevel.ERROR, error); + return undefined; + } + }).filter((testIdContents: TestIdContents | undefined): boolean => testIdContents !== undefined) as TestIdContents[]; + if (ppaasTestIds.length === 0) { + return undefined; + } + const promises: Promise[] = ppaasTestIds.map((testIdContents: TestIdContents) => + getFileContentsS3({ + filename: createS3Filename(testIdContents.ppaasTestId), + s3Folder: testIdContents.ppaasTestId.s3Folder + }) + .then((contents: string | undefined) => ({ ...testIdContents, contents })) + .then((testIdContentsRead: TestIdContents) => { + if (!testIdContentsRead.contents) { return undefined; } + const testStatusMessage: TestStatusMessage = JSON.parse(testIdContentsRead.contents); + log(`PpaasTestStatus getFileContents(${testIdContentsRead.s3File.Key})`, LogLevel.DEBUG, { testStatusMessage }); + const newMessage = new PpaasTestStatus(testIdContentsRead.ppaasTestId, testStatusMessage); + const s3File = s3Files[0]; + // We have to return a PpaasTestStatus instead of a TestStatusMessage so we can return the lastModifiedRemote + if (s3File.LastModified) { + newMessage.lastModifiedRemote = s3File.LastModified; + } + log(`PpaasTestStatus.getStatus(${testIdContentsRead.ppaasTestId.s3Folder})`, LogLevel.DEBUG, { newMessage: newMessage.sanitizedCopy() }); + return newMessage; + }).catch((error) => { + log(`Could not retrieve statuses for s3Folder ${testIdContents.ppaasTestId.s3Folder}`, LogLevel.ERROR, error); + throw error; + }) + ); + return promises; + } + return undefined; + } + + public async readStatus (force?: boolean): Promise { + const updatedStatus: PpaasTestStatus | boolean = await PpaasTestStatus.getStatusInternal( + this.ppaasTestId, + force ? undefined : this.lastModifiedRemote + ); + if (updatedStatus === false) { + throw new Error(`PpaasTestStatus Could not find ${createS3Filename(this.ppaasTestId)} in S3`); + } else if (updatedStatus === true) { + log(`PpaasTestStatus.readStatus(${this.ppaasTestId.s3Folder}) not modified`, LogLevel.DEBUG, { updatedStatus, lastModifiedRemote: this.lastModifiedRemote }); + return this.lastModifiedRemote; + } + Object.assign(this, updatedStatus.getTestStatusMessage()); + this.lastModifiedRemote = updatedStatus.lastModifiedRemote; + log(`PpaasTestStatus.readStatus(${this.ppaasTestId.s3Folder})`, LogLevel.DEBUG, { PpaasTestStatus: this.sanitizedCopy() }); + return this.lastModifiedRemote; + } + + public async writeStatus (): Promise { + // Send the S3 Message + const testStatus: TestStatusMessage = this.getTestStatusMessage(); + + log("PpaasTestStatus Sending new status message to s3", LogLevel.DEBUG, this.sanitizedCopy()); + const newDate = new Date(); + this.url = await uploadFileContents({ + contents: JSON.stringify(testStatus), + filename: createS3Filename(this.ppaasTestId), + s3Folder: this.ppaasTestId.s3Folder, + publicRead: true, + contentType: "application/json", + tags: defaultTestFileTags() + }); + this.lastModifiedRemote = newDate; // Update the last modified + log(`PpaasTestStatus PpaasTestStatus.send url: ${this.url}`, LogLevel.INFO, this.sanitizedCopy()); + return this.url; + } + // Unlike messages, we don't want to delete this +} + +export default PpaasTestStatus; diff --git a/common/src/s3file.ts b/common/src/s3file.ts new file mode 100644 index 00000000..522020d4 --- /dev/null +++ b/common/src/s3file.ts @@ -0,0 +1,316 @@ +import * as path from "path"; +import { + BUCKET_URL, + GetTagsOptions, + KEYSPACE_PREFIX, + copyFile, + defaultTestFileTags, + getFile, + getTags, + init as initS3, + listFiles, + uploadFile +} from "./util/s3"; +import { Body, S3File } from "../types"; +import { LogLevel, log } from "./util/log"; +import { access, stat } from "fs/promises"; +import { _Object as S3Object } from "@aws-sdk/client-s3"; +import { Stats } from "fs"; +import { URL } from "url"; +import { sleep } from "./util/util"; + +const RESULTS_UPLOAD_RETRY: number = parseInt(process.env.RESULTS_UPLOAD_RETRY || "0", 10) || 5; + +export interface PpaasS3FileOptions { + filename: string | undefined; + s3Folder: string | undefined; + localDirectory: string | undefined; + publicRead?: boolean; + tags?: Map; +} + +export interface GetAllFilesInS3Options { + s3Folder: string; + localDirectory: string; + extension?: string; + maxFiles?: number; +} + + export interface PpaasS3FileCopyOptions { + /** Destination s3 folder */ + destinationS3Folder: string; + /** Optional: Change the name of the file */ + destinationFilename?: string; + /** Optional: If true, new file is publicly readable */ + publicRead?: boolean; +} + +export class PpaasS3File implements S3File { + public body: Body | undefined; + public key: string; + public storageClass?: string; + public contentType: string; + public contentEncoding?: string; + public publicRead?: boolean; + public tags?: Map; + public readonly s3Folder: string; + public readonly filename: string; + public readonly localDirectory: string; + public readonly localFilePath: string; + public remoteUrl: string; + protected lastModifiedLocal: number; // From fs.stats.mtimeMs + protected lastModifiedRemote: Date; + // Protected member so only unit tests that extend this class can set it. + + // The receiptHandle is not in the constructor since sending messages doesn't require it. Assign it separately + public constructor ({ filename, s3Folder, localDirectory, publicRead, tags = defaultTestFileTags() }: PpaasS3FileOptions) { + try { + initS3(); + } catch (error: unknown) { + log("Could not initialize s3", LogLevel.ERROR, error); + throw error; + } + if (!filename || !s3Folder || localDirectory === undefined) { + log("PpaasS3File was missing data", LogLevel.ERROR, { filename, s3Folder, localDirectory }); + throw new Error("New Test Message was missing filename, s3Folder, or localDirectory"); + } + this.filename = filename; + s3Folder = s3Folder.startsWith(KEYSPACE_PREFIX) ? s3Folder.slice(KEYSPACE_PREFIX.length) : s3Folder; + this.s3Folder = s3Folder; + this.localDirectory = localDirectory; + this.key = `${s3Folder}/${filename}`; + this.publicRead = publicRead; + this.tags = tags; + this.localFilePath = path.join(localDirectory, filename); + this.lastModifiedLocal = 0; // It hasn't been uploaded yet + this.lastModifiedRemote = new Date(0); // It hasn't been downloaded yet + // Build the remoteUrl. It's usually only set on uploads + this.remoteUrl = new URL(`${KEYSPACE_PREFIX}${this.key}`, BUCKET_URL).href; + const extension: string = path.extname(filename); + log(`extension: ${extension}`, LogLevel.DEBUG); + // Check the file extension for type + switch (extension) { + case ".csv": + this.contentType = "text/csv"; + break; + case ".yaml": + this.contentType = "text/x-yaml"; + break; + case ".json": + this.contentType = "application/json"; + break; + default: + this.contentType = "text/plain"; + break; + } + if (filename === "pewpew" || filename === "pewpew.exe") { + this.contentType = "application/octet-stream"; + } + log(`contentType: ${this.contentType}`, LogLevel.DEBUG); + } + + protected static async getTags (functionName: string, { filename, s3Folder }: GetTagsOptions): Promise | undefined> { + // Get prior tags + try { + const tags = await getTags({ filename, s3Folder }); + return tags; + } catch (error: unknown) { + log(functionName + " - Could not retrieve tags: " + filename, LogLevel.WARN, error); + } + return undefined; + } + + public static async getAllFilesInS3 ({ s3Folder, localDirectory, extension, maxFiles }: GetAllFilesInS3Options): Promise { + log(`Finding in s3Folder: ${s3Folder}, extension: ${extension}, maxFiles: ${maxFiles}`, LogLevel.DEBUG); + const s3Files: S3Object[] = await listFiles({ s3Folder, maxKeys: maxFiles, extension }); + if (s3Files.length === 0) { + return []; + } + // Let the listFiles error throw above + try { + const ppaasFiles: PpaasS3File[] = await Promise.all(s3Files.filter((s3File: S3Object) => s3File && s3File.Key) + .map(async (s3File: S3Object) => { + // find the part after the s3Folder. We may have a prefix added to us so it may not be at the beginning + // If s3Folder is part of a folder we need to split on the / not on the folder name + const key: string = s3File.Key!.startsWith(KEYSPACE_PREFIX) ? s3File.Key!.slice(KEYSPACE_PREFIX.length) : s3File.Key!; + const s3KeySplit = key.split("/"); + const realFolder = s3KeySplit.slice(0, -1).join("/"); + const filename = s3KeySplit[s3KeySplit.length - 1]; + log(`Found S3File ${filename} in ${realFolder}`, LogLevel.DEBUG, s3File); + // Get prior tags + const tags: Map | undefined = await this.getTags("getAllFilesInS3", { filename, s3Folder: realFolder }); + const ppaasS3File = new PpaasS3File({ filename, s3Folder: realFolder, localDirectory, tags }); + // We need to get and Store the LastModified so we can sort and get the latest + if (s3File.LastModified) { + ppaasS3File.lastModifiedRemote = s3File.LastModified; + } + return ppaasS3File; + })); + return ppaasFiles; + } catch (error: unknown) { + log(`getAllFilesInS3(${s3Folder}, ${localDirectory}) failed`, LogLevel.ERROR, error); + throw error; + } + } + + public static async existsInS3 (s3FilePath: string): Promise { + const s3Files: S3Object[] = await listFiles(s3FilePath); + return s3Files.length > 0; + } + + public async existsInS3 (): Promise { + const s3Files: S3Object[] = await listFiles(this.key); + return s3Files.length > 0; + } + + public async existsLocal (): Promise { + try { + await access(this.localFilePath); + return true; + } catch (error: unknown) { + return false; + } + } + + public getLastModifiedRemote (): Date { + return this.lastModifiedRemote; + } + + public getS3File () : S3File { + return { + body: this.body, + key: this.key, + storageClass: this.storageClass, + contentType: this.contentType, + contentEncoding: this.contentEncoding, + publicRead: this.publicRead, + tags: this.tags + }; + } + + // Create a sanitized copy which doesn't have the environment variables which may have passwords + public sanitizedCopy (): S3File & { + s3Folder: string, + filename: string, + localDirectory: string, + localFilePath: string, + remoteUrl: string, + lastModifiedLocal: number, + lastModifiedRemote: Date + } { + const returnObject: S3File & { + s3Folder: string, + filename: string, + localDirectory: string, + localFilePath: string, + remoteUrl: string, + lastModifiedLocal: number, + lastModifiedRemote: Date + } = { + ...this.getS3File(), + body: undefined, + s3Folder: this.s3Folder, + filename: this.filename, + localDirectory: this.localDirectory, + localFilePath: this.localFilePath, + remoteUrl: this.remoteUrl, + lastModifiedLocal: this.lastModifiedLocal, + lastModifiedRemote: this.lastModifiedRemote + }; + return JSON.parse(JSON.stringify(returnObject)); + } + + // Override toString so we can not log the environment variables which may have passwords + public toString (): string { + return JSON.stringify(this.sanitizedCopy()); + } + + // Returns the local Filepath + public async download (force?: boolean): Promise { + // Update last modified remote + const downloadedDate: Date | undefined = await getFile({ + filename: this.filename, + s3Folder: this.s3Folder, + localDirectory: this.localDirectory, + lastModified: force ? undefined : this.lastModifiedRemote + }); + if (downloadedDate) { + this.lastModifiedRemote = downloadedDate; + const tags: Map | undefined = await PpaasS3File.getTags("copy", { filename: this.filename, s3Folder: this.s3Folder }); + if (tags && tags.size > 0) { + this.tags = tags; + } + } + return this.localFilePath; + } + + public async upload (force?: boolean, retry?: boolean): Promise { + const stats: Stats = await stat(this.localFilePath); + log(`${this.filename} old lastModified: ${this.lastModifiedLocal}, forice: ${force}`, LogLevel.DEBUG, stats); + // If we're not forcing it, check the last modified + if (!force && stats.mtimeMs === this.lastModifiedLocal) { + return; + } + // If it's retry it's the last time, log it for real + log(`Uploading ${this.filename}`, LogLevel.DEBUG); + let retryCount: number = 0; + let caughtError: any; + let uploaded: boolean = false; + do { + try { + if (retryCount > 0) { + // Only sleep if we're on the 2nd time through or more + await sleep((retryCount * 1000) + Math.floor(Math.random() * Math.floor(retryCount))); + } + log(`Uploading ${this.filename}: ${retryCount++}`, LogLevel.DEBUG); + this.remoteUrl = await uploadFile({ + filepath: this.localFilePath, + s3Folder: this.s3Folder, + publicRead: this.publicRead, + contentType: this.contentType, + tags: this.tags + }); + uploaded = true; + // Update last modified local + this.lastModifiedLocal = stats.mtimeMs; + } catch (error: unknown) { + log(`Error uploading ${this.filename}`, LogLevel.ERROR, error); + caughtError = error; + // We'll throw it later after all retries + } + } while (!uploaded && retry && retryCount < RESULTS_UPLOAD_RETRY); + if (!uploaded) { + throw (caughtError || new Error("Could not upload " + this.filename)); + } + } + + /** + * Copies the PpaasS3File to a new location in S3 (or a new filename) + * @param param0 {PpaasS3FileCopyOptions} parameters + * @returns A new PpaasS3File that represents the copied object + */ + public async copy ({ destinationS3Folder, destinationFilename, publicRead }: PpaasS3FileCopyOptions): Promise { + const lastModified: Date | undefined = await copyFile({ + filename: this.filename, + sourceS3Folder: this.s3Folder, + destinationS3Folder, + destinationFilename, + publicRead + }); + // Get prior tags + const tags: Map | undefined = await PpaasS3File.getTags("copy", { filename: this.filename, s3Folder: this.s3Folder }); + const copiedS3File: PpaasS3File = new PpaasS3File({ + filename: destinationFilename || this.filename, + s3Folder: destinationS3Folder, + localDirectory: this.localDirectory, + publicRead: publicRead || this.publicRead, + tags + }); + if (lastModified) { + copiedS3File.lastModifiedRemote = lastModified; + } + return copiedS3File; + } +} + +export default PpaasS3File; diff --git a/common/src/util/ec2.ts b/common/src/util/ec2.ts new file mode 100644 index 00000000..ec4d0f8a --- /dev/null +++ b/common/src/util/ec2.ts @@ -0,0 +1,59 @@ +import { LogLevel, log } from "./log"; +import { + EC2Client +} from "@aws-sdk/client-ec2"; +import { exec as _exec } from "child_process"; +import { promisify } from "util"; +import { readFile } from "fs/promises"; +const exec = promisify(_exec); + +// Create these later so the profile can be set dynamically +let ec2Client: EC2Client; + +// Init function to initialize these after we've started but on first access +export function init (): void { + if (!ec2Client) { + ec2Client = new EC2Client({ + region: "us-east-1" + }); + } +} + +export const INSTANCE_ID_FILE = "/var/lib/cloud/data/instance-id"; +export const INSTANCE_ID_REGEX = /^i-[0-9a-z]+$/; +export const INSTANCE_FILE_REGEX = /^instance-id:\s*(i-[0-9a-z]+)$/; +export const INSTANCE_ID_COMMAND = "ec2-metadata --instance-id"; +export async function getInstanceId (): Promise { + // Try to load from file first + // $ cat /var/lib/cloud/data/instance-id -> "i-0cfd3309705a3ce79" + try { + const instanceId: string = (await readFile(INSTANCE_ID_FILE, "utf-8")).trim(); + log(`${INSTANCE_ID_FILE} instanceId: [${instanceId}]`, LogLevel.DEBUG, { instanceId }); + if (!INSTANCE_ID_REGEX.test(instanceId)) { + log(`InstanceId did not match regex [${instanceId}]`, LogLevel.WARN, { match: instanceId?.match(INSTANCE_ID_REGEX), INSTANCE_ID_REGEX }); + throw new Error("InstanceId did not match regex"); + } + return instanceId; + } catch (error: unknown) { + log(`Could not load instanceId file "${INSTANCE_ID_FILE}"`, LogLevel.WARN, error); + } + // Then try ec2-metadata command + // $ ec2-metadata --instance-id | cut -d " " -f 2 + // $ ec2-metadata --instance-id -> "instance-id: i-0cfd3309705a3ce79" + try { + const { stderr, stdout } = await exec(INSTANCE_ID_COMMAND); + const match: RegExpMatchArray | null = stdout?.match(INSTANCE_FILE_REGEX); + log(`${INSTANCE_ID_COMMAND}: [${stdout}]`, LogLevel.DEBUG, { stdout, stderr, match }); + if (!match || match.length !== 2) { + log(`InstanceId did not match regex [${stdout}]`, LogLevel.WARN, { stderr, stdout, match, INSTANCE_ID_REGEX }); + throw new Error("InstanceId did not match regex"); + } + const instanceId = match[1]; + log(`${INSTANCE_ID_COMMAND} instanceId: [${instanceId}]`, LogLevel.DEBUG, { instanceId, match }); + return instanceId; + } catch (error: unknown) { + log(`Could not load instanceId command "${INSTANCE_ID_COMMAND}"`, LogLevel.WARN, error); + } + // curl http://169.254.169.254/latest/meta-data/instance-id + throw new Error("Could not load instanceId"); +} diff --git a/common/src/util/log.ts b/common/src/util/log.ts new file mode 100644 index 00000000..5fda14b7 --- /dev/null +++ b/common/src/util/log.ts @@ -0,0 +1,142 @@ +/* eslint-disable no-console */ +import * as Logger from "bunyan"; +import { LogLevel } from "../../types"; +import { join as pathJoin } from "path"; + +export { LogLevel } ; + +export const config = { + LogFileName: process.env.LogFileName || "ppaas-common", + LoggingLevel: process.env.LoggingLevel as Logger.LogLevel || "info", + LoggingLevelConsole: process.env.LoggingLevelConsole as Logger.LogLevel || "warn", + LogFileLocation: process.env.LOG_FILE_LOCATION || "." +}; + +export const pewpewStdOutFilename = (testId: string) => `app-ppaas-pewpew-${testId}-out.json`; +export const pewpewStdErrFilename = (testId: string) => `app-ppaas-pewpew-${testId}-error.json`; + +export const PEWPEW_SPLUNK_INJECTED_VARIABLES: string[] = ["SPLUNK_PATH", "SPLUNK_FOLDER", "SPLUNK_LOCATION", "SPLUNK_LOGS"]; + +let logger: Logger; + +export function getLogger (): Logger { + if (!logger) { + logger = Logger.createLogger({ + name: config.LogFileName, + streams: [ + { + level: config.LoggingLevelConsole, + stream: process.stdout + }, + { + level: config.LoggingLevel, + type: "rotating-file", + path: pathJoin(config.LogFileLocation, `app-${config.LogFileName}.json`), // Must be named app*.json + period: "1d", // daily rotation + count: 2 // Keep 3 back copies + } + ] + }); + logger.warn({ message: `Logging Level set to ${config.LoggingLevel} for app-${config.LogFileName}.json` }); + logger.warn({ message: `Console Logging Level set to ${config.LoggingLevelConsole}` }); + logger.info({ message: "Environment Variables", + APPLICATION_NAME: process.env.APPLICATION_NAME, + SYSTEM_NAME: process.env.SYSTEM_NAME, + SERVICE_NAME: process.env.SERVICE_NAME, + NODE_ENV: process.env.NODE_ENV, + variables: Object.keys(process.env).filter((variableName: string) => variableName && !variableName.startsWith("npm")) + }); + } + return logger; +} + +/** + * Takes a variable list of data and logs to the file and console based on + * process.env.LoggingLevel and process.env.LoggingLevelConsole + * @param message (String) primary message to log + * @param level (LogLevel) default: INFO. The level to log this message at + * @param datas (...any[]) Optional objects to log including a single error + */ + export function log (message: string, level: LogLevel = LogLevel.INFO, ...datas: any[]) { + const fullMessage: any = { + message + }; + let i = 0; + if (datas && datas.length > 0) { + for (const data of datas) { + if (data) { + if (data instanceof Error) { + // Only allow one. Overwrite otherwise + fullMessage.error = data.message || `${data}`; + // console.error(data); + if (!message && !data.message) { console.error(data.stack || new Error("Error with no stack")); } + if (!message) { message = data.message; } + } else if (data instanceof Map) { + // Only allow one. Overwrite otherwise + fullMessage.data = Object.assign(fullMessage.data || {}, { map: [...data] }); + } else if (typeof data === "string" || Array.isArray(data)) { + // Object.assign doesn't work combining objects and arrays + // instanceOf String didn't work and caused an object.assign + if (datas.length > 1) { + // If there's more than one, add a counter to differentiate + // data0 will be different than data to avoid objects overwriting this + fullMessage["data" + i++] = data; + } else { + fullMessage.data = data; + } + } else { + // If we have a testId and/or yamlFile, put them in the main message and remove them. + // We can't delete them from the real one passed in, so create a cloned copy + const dataCopy = Object.assign({}, data); + if (dataCopy.testId && typeof dataCopy.testId === "string") { + fullMessage.testId = dataCopy.testId; + delete dataCopy.testId; + } + if (dataCopy.yamlFile && typeof dataCopy.yamlFile === "string") { + fullMessage.yamlFile = dataCopy.yamlFile; + delete dataCopy.yamlFile; + } + if (dataCopy.userId && typeof dataCopy.userId === "string") { + fullMessage.userId = dataCopy.userId; + delete dataCopy.userId; + } + // If all we had was a testId and/or yamlFile it'll be an empty object. Don't log it + if (Object.keys(dataCopy).length > 0) { + // If there's already an object, do an Object.assign on top of it. + // This will never be a string because of the length check above on typeof == string + fullMessage.data = Object.assign(fullMessage.data || {} , dataCopy); + } + } + } + } + } + getLogger(); // Call this to initialize if it hasn't been + switch (level) { + case LogLevel.TRACE: + logger.trace(fullMessage); + break; + case LogLevel.DEBUG: + logger.debug(fullMessage); + break; + case LogLevel.INFO: + logger.info(fullMessage); + break; + case LogLevel.WARN: + logger.warn(fullMessage); + break; + case LogLevel.ERROR: + logger.error(fullMessage); + // eslint-disable-next-line eqeqeq + if ((!fullMessage.message || fullMessage.message == "undefined") && !fullMessage.error) { + console.error(new Error("Log Error with NO message or error")); + } + break; + case LogLevel.FATAL: + logger.fatal(fullMessage); + console.error(new Error("Log Fatal Stack Trace")); + break; + default: + logger.info(fullMessage); + break; + } +} diff --git a/common/src/util/s3.ts b/common/src/util/s3.ts new file mode 100644 index 00000000..dc5ee225 --- /dev/null +++ b/common/src/util/s3.ts @@ -0,0 +1,685 @@ +import * as path from "path"; +import { + AbortMultipartUploadCommandOutput, + CompleteMultipartUploadCommandOutput, + CopyObjectCommand, + CopyObjectCommandInput, + CopyObjectCommandOutput, + DeleteObjectCommand, + DeleteObjectCommandInput, + DeleteObjectCommandOutput, + GetObjectCommand, + GetObjectCommandInput, + GetObjectCommandOutput, + GetObjectTaggingCommand, + GetObjectTaggingCommandInput, + GetObjectTaggingCommandOutput, + ListObjectsV2Command, + ListObjectsV2CommandInput, + ListObjectsV2CommandOutput, + PutObjectCommandInput, + PutObjectTaggingCommand, + PutObjectTaggingCommandInput, + PutObjectTaggingCommandOutput, + S3Client, + _Object as S3Object, + S3ServiceException, + Tag as S3Tag +} from "@aws-sdk/client-s3"; +import { LogLevel, log } from "./log"; +import { createGzip, gunzip as zlibGunzip} from "zlib"; +import { createReadStream, writeFile as fsWriteFile } from "fs"; +import { S3File } from "../../types"; +import { Upload } from "@aws-sdk/lib-storage"; +import { constants as bufferConstants } from "node:buffer"; +import { getPrefix } from "./util"; +import { promisify } from "util"; +import stream from "stream"; +const { MAX_STRING_LENGTH } = bufferConstants; + +export type { S3File }; +const gunzip = promisify(zlibGunzip); +const writeFile = promisify(fsWriteFile); + +export let BUCKET_NAME: string; +export let BUCKET_URL: string; +export let KEYSPACE_PREFIX: string; +// let REGION_ENDPOINT: string | undefined; +// Export for testing so we can reset s3 +export const config: { s3Client: S3Client } = { + s3Client: undefined as unknown as S3Client +}; +/** + * ADDITIONAL_TAGS_ON_ALL if set via environment variable is expected to be a comma delimited list of key=value pairs + * which will be added to ALL s3 objects uploaded if the key isn't already passed in on the upload object call. + * Example: process.env.ADDITIONAL_TAGS_ON_ALL="key1=value1,key2=value2" would put two tags on every upload. If an upload + * has tags passed in of "key2=value3" then only key1=value1 would be added. WARNING: Is only initialized after s3.init() called. + */ +export const ADDITIONAL_TAGS_ON_ALL = new Map(); +// Don't export so that the original can't be modified +const TEST_FILE_TAGS_INTERNAL = new Map([["test", "true"]]); +/** Returns a new copy of the Map each time so the original can't be modified */ +export const defaultTestFileTags = (): Map => new Map(TEST_FILE_TAGS_INTERNAL); + +/** + * Initializes the S3 object using environment variables. Runs later so it doesn't throw on start-up + */ +export function init (): void { + if (BUCKET_NAME && config.s3Client) { + // If we've already set the BUCKET_NAME then we've done this already. + return; + } + // Where is your application name, system name, and service name concatenated with underscores, capitalized, and all dashes replaced with underscores. + // The s3 service name is s3 in the application which is then capitalized to _S3_ below + + // We need to error check if we're running on application we don't fall back to the unittests on live + const PREFIX: string = getPrefix(true); // Use the controller if we have one + const bucketName: string | undefined = process.env[`${PREFIX}_S3_BUCKET_NAME`]; + log(`${PREFIX}_S3_BUCKET_NAME = ${bucketName}`, LogLevel.DEBUG); + if (!bucketName) { + log(`Could not load the environment variable ${PREFIX}_S3_BUCKET_NAME`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_S3_BUCKET_NAME`); + } + const bucketUrl: string | undefined = process.env[`${PREFIX}_S3_BUCKET_URL`]; + log(`${PREFIX}_S3_BUCKET_URL = ${bucketUrl}`, LogLevel.DEBUG); + if (!bucketUrl) { + log(`Could not load the environment variable ${PREFIX}_S3_BUCKET_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_S3_BUCKET_URL`); + } + // Empty string should be allowed on a private bucket + const keyspacePrefix: string = process.env[`${PREFIX}_S3_KEYSPACE_PREFIX`] || ""; + log(`${PREFIX}_S3_KEYSPACE_PREFIX = ${keyspacePrefix}`, LogLevel.DEBUG); + BUCKET_NAME = bucketName; + BUCKET_URL = bucketUrl; + // Since we don't have a private s3 bucket. This will be populated. If we ever move to a private we don't want to tack on the trailing / + KEYSPACE_PREFIX = keyspacePrefix.length > 0 && !keyspacePrefix.endsWith("/") ? (keyspacePrefix + "/") : keyspacePrefix; + + // Create an S3 service object + config.s3Client = new S3Client({ + // params: { Bucket: BUCKET_NAME }, + region: "us-east-1" + }); + + if (process.env.ADDITIONAL_TAGS_ON_ALL && ADDITIONAL_TAGS_ON_ALL.size === 0) { + try { + for (const keyPair of process.env.ADDITIONAL_TAGS_ON_ALL.split(",")) { + const split = keyPair.split("="); + if (split.length !== 2 || split[0].trim() === "") { // Key can't be an empty string + const errorMessage = "Invalid key_pair: " + keyPair; + log(errorMessage, LogLevel.WARN, { keyPair, split }); + throw new Error(errorMessage); + } + ADDITIONAL_TAGS_ON_ALL.set(split[0], split[1]); + } + log("ADDITIONAL_TAGS_ON_ALL", LogLevel.INFO, ADDITIONAL_TAGS_ON_ALL); + } catch (error: unknown) { + log("Could not parse process.env.ADDITIONAL_TAGS_ON_ALL: " + process.env.ADDITIONAL_TAGS_ON_ALL, LogLevel.WARN, error); + throw error; + } + } +} + +let accessCallback: (date: Date) => void | undefined; + +export function setAccessCallback (fn: (date: Date) => void) { + accessCallback = fn; +} + +function callAccessCallback (date: Date) { + try { + if (accessCallback) { + accessCallback(date); + } else { + log("s3 setAccessCallback has not be set. Cannot call the accessCallback", LogLevel.WARN); + } + } catch (error: unknown) { + log("Calling the Access Callback (set last s3 accessed failed", LogLevel.ERROR, error); + } +} + +export interface FileOptions { + /** filename {string} filename to retrieve */ + filename: string; + /** s3Folder {string} folder in s3 */ + s3Folder: string; +} + +export interface ListFilesOptions { + s3Folder: string; + maxKeys?: number; + extension?: string; +} + +export async function listFiles ({ s3Folder, extension, maxKeys }: ListFilesOptions): Promise; +export async function listFiles (s3Folder: string): Promise; +export async function listFiles (options: string | ListFilesOptions): Promise { + let s3Folder: string; + let maxKeys: number | undefined; + let extension: string | undefined; + if (typeof options === "string") { + s3Folder = options; + } else { + ({ s3Folder, maxKeys, extension } = options); + } + log(`listFiles(${s3Folder}, ${maxKeys}, ${extension})`, LogLevel.DEBUG); + let result: ListObjectsV2CommandOutput | undefined; + const files: S3Object[] = []; + do { + result = await listObjects({ prefix: s3Folder, maxKeys, continuationToken: result && result.NextContinuationToken}); + if (result.Contents) { + if (extension && result.Contents.length > 0) { + const filtered: S3Object[] = result.Contents.filter((s3File: S3Object) => s3File.Key!.endsWith(extension!)); + files.push(...filtered); + } else { + files.push(...(result.Contents)); + } + } + } while (result.IsTruncated && maxKeys !== undefined && files.length < maxKeys); + + return files; +} + +export interface GetFileOptions extends FileOptions { + localDirectory: string; + lastModified?: Date; +} + +/** Returns the last modified date if downloaded, undefined on 304, or throws */ +export async function getFile ({ filename, s3Folder, localDirectory, lastModified }: GetFileOptions): Promise { + if (s3Folder === undefined || localDirectory === undefined) { + throw new Error("localDirectory and s3Folder must be provided"); + } + const key: string = `${s3Folder}/${filename}`; + try { + const localFile: string = path.join(localDirectory, filename); + const result: GetObjectCommandOutput = await getObject(key, lastModified); + if (!result || !result.Body) { + throw new Error("S3 Get Object was empty"); + } + let content: Buffer = Buffer.from(await result.Body.transformToByteArray()); + if (result.ContentEncoding === "gzip") { + content = await gunzip(content); + } + // Write the file to disk + await writeFile(localFile, content); + return result.LastModified; + } catch (error: unknown) { + // Could be a 304. Swallow it + if (error && ((error as S3ServiceException)?.name === "304" || (error as S3ServiceException)["$metadata"]?.httpStatusCode === 304)) { + log("getFile not modified: " + filename, LogLevel.DEBUG, error); + return undefined; + } else { + log(`getFile(${filename}, ${s3Folder}, ${localDirectory}, ${lastModified}) ERROR`, LogLevel.ERROR, error); + throw error; + } + } +} + +export interface GetFileContentsOptions extends FileOptions { + lastModified?: Date; + maxLength?: number +} + +/** + * Retrieves the file contents and returns it as a string + * @param filename {string} filename to retrieve + * @param s3Folder {string} folder in s3 + * @param lastModified {Date} (optional) last modified date from a prior request + * @param maxLength {number} (optional) maximum string length to return + * @returns file contents if downloaded, undefined on 304, or throws + */ +export async function getFileContents ({ filename, s3Folder, lastModified, maxLength = MAX_STRING_LENGTH }: GetFileContentsOptions): Promise { + if (s3Folder === undefined) { + throw new Error("s3Folder must be provided"); + } + const key: string = `${s3Folder}/${filename}`; + try { + const result: GetObjectCommandOutput | undefined = await getObject(key, lastModified); + if (!result || !result.Body) { + throw new Error("S3 Get Object was empty"); + } + let content: Buffer = Buffer.from(await result.Body.transformToByteArray()); + if (result.ContentEncoding === "gzip") { + content = await gunzip(content); + } + log("content.length: " + content.length, LogLevel.DEBUG, { maxLength, contentLength: content.length, MAX_STRING_LENGTH }); + // What should we do if the size of a buffer is larger than max string + if (maxLength && content.length > maxLength) { + log(`getFile(${filename}, ${s3Folder}, ${lastModified}) too long, truncating`, LogLevel.WARN, { length: content.length, maxLength, MAX_STRING_LENGTH }); + } + return content.toString("utf-8", 0, maxLength); + } catch (error: unknown) { + // Could be a 304. Swallow it + if (error && ((error as S3ServiceException)?.name === "304" || (error as S3ServiceException)["$metadata"]?.httpStatusCode === 304)) { + log("getFile not modified: " + filename, LogLevel.DEBUG, error); + return undefined; + } else { + log(`getFile(${filename}, ${s3Folder}, ${lastModified}) ERROR`, LogLevel.ERROR, error); + throw error; + } + } +} + +export interface UploadFileOptions { + filepath: string; + s3Folder: string; + publicRead?: boolean; + contentType?: string; + tags?: Map; +} + +/** Returns the URL location in S3 */ +export async function uploadFile ({ filepath, s3Folder, publicRead, contentType, tags }: UploadFileOptions): Promise { + if (s3Folder === undefined) { + throw new Error("s3Folder must be provided"); + } + if (publicRead === undefined) { + publicRead = false; + } + if (contentType === undefined) { + contentType = "text/plain"; + } + const filename: string = path.basename(filepath); + // Check the file extension for type + const s3File: S3File = { + body: createReadStream(filepath).pipe(createGzip()), + key: `${s3Folder}/${filename}`, + contentEncoding: "gzip", + contentType, + publicRead, + tags + }; + const uploadResult: CompleteMultipartUploadCommandOutput = await uploadObject(s3File); + return uploadResult.Location!; +} + +export interface UploadFileContentsOptions extends FileOptions { + contents: string; + publicRead?: boolean; + contentType?: string; + tags?: Map; +} + +/** Returns the URL location in S3 */ +export async function uploadFileContents ({ contents, filename, s3Folder, publicRead, contentType, tags }: UploadFileContentsOptions): Promise { + if (filename === undefined || s3Folder === undefined) { + throw new Error("filename and s3Folder must be provided"); + } + if (publicRead === undefined) { + publicRead = false; + } + if (contentType === undefined) { + contentType = "text/plain"; + } + const baseName: string = path.basename(filename); + const bufferStream = new stream.PassThrough(); + bufferStream.end(Buffer.from(contents)); + const s3File: S3File = { + body: bufferStream.pipe(createGzip()), + key: `${s3Folder}/${baseName}`, + contentEncoding: "gzip", + contentType, + publicRead, + tags + }; + const uploadResult: CompleteMultipartUploadCommandOutput = await uploadObject(s3File); + return uploadResult.Location!; +} + +/** + * Interface/object for copying objects in S3 + */ +export interface CopyFileOptions { + /** source filename to be copied */ + filename: string; + /** Source s3 folder to copy from */ + sourceS3Folder: string; + /** Destination s3 folder */ + destinationS3Folder: string; + /** Optional: Change the name of the file */ + destinationFilename?: string; + /** Optional: If true, new file is publicly readable */ + publicRead?: boolean; + /** Optional: tags to set on the new object. If not provided, the old tags will be copied */ + tags?: Map; +} + +/** + * Copies the file from an s3 location to a new location + * @param filename {string} source filename to be copied + * @param sourceS3Folder {string} Source s3 folder to copy from + * @param destinationS3Folder {string} Destination s3 folder + * @param destinationFilename {string} Optional: Change the name of the file + * @param publicRead {boolean} Optional: If true, new file is publicly readable + * @returns The new last modified date + */ +export async function copyFile ({ filename, sourceS3Folder, destinationS3Folder, destinationFilename, publicRead = false, tags }: CopyFileOptions): Promise { + if (destinationS3Folder === sourceS3Folder + && (destinationFilename === undefined || destinationFilename === filename)) { + // Can't copy to itself + throw new Error("copyFile cannot copy to itself"); + } + // Check the file extension for type + const sourceFile: S3File = { + key: `${sourceS3Folder}/${filename}`, + contentEncoding: "gzip", + contentType: "ignored", + publicRead + }; + const destinationFile: S3File = { + key: `${destinationS3Folder}/${destinationFilename || filename}`, + contentEncoding: "gzip", + contentType: "ignored", + publicRead + }; + const result: CopyObjectCommandOutput = await copyObject({ sourceFile, destinationFile, tags }); + return result.CopyObjectResult?.LastModified; +} + +export type DeleteFileOptions = FileOptions; + +export async function deleteFile ({ filename, s3Folder }: DeleteFileOptions): Promise { + await deleteObject(`${s3Folder}/${filename}`); +} + +export type GetTagsOptions = FileOptions; + +/** + * Retrieves the file contents and returns it as a string + * @param filename {string} filename to retrieve + * @param s3Folder {string} folder in s3 + * @returns file contents if downloaded, undefined on 304, or throws + */ +export async function getTags ({ filename, s3Folder }: GetTagsOptions): Promise | undefined> { + const key: string = `${s3Folder}/${filename}`; + try { + const result: GetObjectTaggingCommandOutput = await getObjectTagging(key); + let tags: Map | undefined; + for (const tag of result.TagSet || []) { + if (tags === undefined) { tags = new Map(); } + log("Adding tag for: " + key, LogLevel.DEBUG, tag); + if (tag.Key && tag.Value) { tags.set(tag.Key, tag.Value); } + } + return tags; + } catch (error: unknown) { + log(`getTags(${filename}, ${s3Folder}) ERROR`, LogLevel.ERROR, error); + throw error; + } +} + +export interface PutTagsOptions extends FileOptions { + tags: Map; +} + +/** + * Retrieves the file contents and returns it as a string + * @param filename {string} filename to retrieve + * @param s3Folder {string} folder in s3 + * @returns file contents if downloaded, undefined on 304, or throws + */ +export async function putTags ({ filename, s3Folder, tags }: PutTagsOptions): Promise { + const key: string = `${s3Folder}/${filename}`; + try { + await putObjectTagging({ key, tags }); + } catch (error: unknown) { + log(`putTags(${filename}, ${s3Folder}) ERROR`, LogLevel.ERROR, error); + throw error; + } +} + +// NOTE: Only the low level functions getObject, uploadObject, and listObjects will prepend the KEYSPACE_PREFIX + +export interface ListObjectsOptions { + prefix?: string; + maxKeys?: number; + continuationToken?: string; +} + +// export for testing +export async function listObjects (prefix?: string): Promise; +export async function listObjects (options?: ListObjectsOptions): Promise; +export async function listObjects (options?: string | ListObjectsOptions): Promise { + let prefix: string | undefined; + let maxKeys: number | undefined; + let continuationToken: string | undefined; + if (typeof options === "string") { + prefix = options; + } else { + ({ prefix, maxKeys, continuationToken } = options || {}); + } + log(`listObjects(${prefix}, ${maxKeys}, ${continuationToken})`, LogLevel.DEBUG); + init(); + if (!prefix || !prefix.startsWith(KEYSPACE_PREFIX)) { + prefix = KEYSPACE_PREFIX + (prefix || ""); + } + const params: ListObjectsV2CommandInput = { + Bucket: BUCKET_NAME, + Prefix: prefix, + ContinuationToken: continuationToken, + MaxKeys: maxKeys || 50 + }; + try { + log("listObjects request", LogLevel.DEBUG, params); + const result: ListObjectsV2CommandOutput = await config.s3Client.send(new ListObjectsV2Command(params)); + log("listObjects succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("listObjects failed on prefix: " + prefix, LogLevel.ERROR, error); + throw error; + } +} + +// export for testing +export async function getObject (key: string, lastModified?: Date): Promise { + init(); + if (!key || !key.startsWith(KEYSPACE_PREFIX)) { + key = KEYSPACE_PREFIX + (key || ""); + } + const params: GetObjectCommandInput = { + Bucket: BUCKET_NAME, + Key: key, + IfModifiedSince: lastModified + }; + try { + log("getObject request", LogLevel.DEBUG, params); + const result: GetObjectCommandOutput = await config.s3Client.send(new GetObjectCommand(params)); + log("getObject succeeded", LogLevel.DEBUG, Object.assign({}, result, { Body: undefined })); // Log it without the body + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + // Can return "Not Modified", don't log it + if (error && ((error as S3ServiceException)?.name === "304" || (error as S3ServiceException)["$metadata"]?.httpStatusCode === 304)) { + callAccessCallback(new Date()); // Update the last timestamp + log("getObject not modified", LogLevel.DEBUG, error); + } else { + log("getObject failed", LogLevel.WARN, error); + } + throw error; + } +} + +// export for testing +export async function uploadObject (file: S3File): Promise { + init(); + if (!file.key || !file.key.startsWith(KEYSPACE_PREFIX)) { + file.key = KEYSPACE_PREFIX + (file.key || ""); + } + let tags: Map; + if (!file.tags) { + tags = new Map(); + } else { + // Create a copy so we don't modify the original if we add more tags + tags = new Map(file.tags); + } + for (const [tagKey, tagValue] of ADDITIONAL_TAGS_ON_ALL) { + if (!tags.has(tagKey)) { + tags.set(tagKey, tagValue); + } + } + // Must be url encoded `testing=Moo&testing2=Baa` + let taggingString: string = ""; + for (const [key, value] of tags.entries()) { + const formattedPair = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + taggingString += (taggingString.length > 0 ? "&" : "") + formattedPair; + } + const params: PutObjectCommandInput = { + ACL: file.publicRead ? "public-read" : "authenticated-read", + Body: file.body, + Bucket: BUCKET_NAME, + CacheControl: "max-age=60", + ContentType: file.contentType, + ContentEncoding: file.contentEncoding, + Key: file.key, + StorageClass: file.storageClass, + Tagging: taggingString + }; + try { + log("uploadObject request", LogLevel.DEBUG, Object.assign({}, params, { Body: undefined })); // Log it without the body + const upload = new Upload({ + client: config.s3Client, + // tags: file.tags && [...file.tags].map(([Key, Value]) => ({ Key, Value })), + params + }); + upload.on("httpUploadProgress", (progress) => { + log("uploadObject httpUploadProgress", LogLevel.DEBUG, { ...params, Body: undefined, progress }); // Log it without the body + }); + const result: CompleteMultipartUploadCommandOutput | AbortMultipartUploadCommandOutput = await upload.done(); + // const result: S3.ManagedUpload.SendData = await config.s3.upload(params).promise(); + if (!("Location" in result) || !result.Location) { + log("uploadObject failed", LogLevel.WARN, { ...params, Body: undefined, result }); + throw new Error("Upload failed, no Location returned"); + } + log("uploadObject succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("uploadObject failed", LogLevel.WARN, error); + throw error; + } +} + +export interface CopyObjectOptions { + sourceFile: S3File; + destinationFile: S3File; + tags?: Map; +} +/** + * Copies the s3 object to the new location copying the metadata and tags. The only properties that + * can be changed is the `publicRead` property. + * @param param0 {CopyObjectOptions} + * @returns {CopyObjectCommandOutput} + */ +export async function copyObject ({ sourceFile, destinationFile, tags }: CopyObjectOptions): Promise { + init(); + if (!sourceFile.key || !sourceFile.key.startsWith(KEYSPACE_PREFIX)) { + sourceFile.key = KEYSPACE_PREFIX + (sourceFile.key || ""); + } + if (!destinationFile.key || !destinationFile.key.startsWith(KEYSPACE_PREFIX)) { + destinationFile.key = KEYSPACE_PREFIX + (destinationFile.key || ""); + } + let taggingString: string = ""; + for (const [key, value] of (tags || [])) { + const formattedPair = `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + taggingString += (taggingString.length > 0 ? "&" : "") + formattedPair; + } + const params: CopyObjectCommandInput = { + ACL: destinationFile.publicRead ? "public-read" : "authenticated-read", + CopySource: `${BUCKET_NAME}/${sourceFile.key}`, + Bucket: BUCKET_NAME, + Key: destinationFile.key, + MetadataDirective: "COPY", + TaggingDirective: tags ? "REPLACE" : "COPY", + Tagging: tags && taggingString + }; + try { + log("copyObject request", LogLevel.DEBUG, Object.assign({}, params, { Body: undefined })); // Log it without the body + const result: CopyObjectCommandOutput = await config.s3Client.send(new CopyObjectCommand(params)); + log("copyObject succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("copyObject failed", LogLevel.WARN, error); + throw error; + } +} + +// export for testing +export async function deleteObject (s3FileKey: string): Promise { + init(); + if (!s3FileKey || !s3FileKey.startsWith(KEYSPACE_PREFIX)) { + s3FileKey = KEYSPACE_PREFIX + (s3FileKey || ""); + } + const params: DeleteObjectCommandInput = { + Bucket: BUCKET_NAME, + Key: s3FileKey + }; + try { + log("deleteObject request", LogLevel.DEBUG, params); + const result: DeleteObjectCommandOutput = await config.s3Client.send(new DeleteObjectCommand(params)); + log(`deleteObject ${s3FileKey} succeeded`, LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("deleteObject failed", LogLevel.WARN, error); + throw error; + } +} + +export async function getObjectTagging (s3FileKey: string): Promise { + init(); + if (!s3FileKey || !s3FileKey.startsWith(KEYSPACE_PREFIX)) { + s3FileKey = KEYSPACE_PREFIX + (s3FileKey || ""); + } + const params: GetObjectTaggingCommandInput = { + Bucket: BUCKET_NAME, + Key: s3FileKey + }; + try { + log("getObjectTagging request", LogLevel.DEBUG, params); + const result: GetObjectTaggingCommandOutput = await config.s3Client.send(new GetObjectTaggingCommand(params)); + log(`getObjectTagging ${s3FileKey} succeeded`, LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("getObjectTagging failed", LogLevel.WARN, error); + throw error; + } +} + +export interface PutObjectTaggingOptions { + key: string; + tags: Map; +} + +export async function putObjectTagging ({ key, tags }: PutObjectTaggingOptions): Promise { + init(); + if (!key || !key.startsWith(KEYSPACE_PREFIX)) { + key = KEYSPACE_PREFIX + (key || ""); + } + if (ADDITIONAL_TAGS_ON_ALL.size > 0) { + // Create a copy so we don't modify the original if we add more tags + tags = new Map(tags); + for (const [tagKey, tagValue] of ADDITIONAL_TAGS_ON_ALL) { + if (!tags.has(tagKey)) { + tags.set(tagKey, tagValue); + } + } + } + const TagSet: S3Tag[] = [...tags].map(([Key, Value]: [string, string]) => ({ Key, Value })); + const params: PutObjectTaggingCommandInput = { + Bucket: BUCKET_NAME, + Key: key, + Tagging: { TagSet } + }; + try { + log("putObjectTagging request", LogLevel.DEBUG, params); + const result: PutObjectTaggingCommandOutput = await config.s3Client.send(new PutObjectTaggingCommand(params)); + log(`putObjectTagging ${key} succeeded`, LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("putObjectTagging failed", LogLevel.WARN, error); + throw error; + } +} diff --git a/common/src/util/sqs.ts b/common/src/util/sqs.ts new file mode 100644 index 00000000..71ab9a3f --- /dev/null +++ b/common/src/util/sqs.ts @@ -0,0 +1,574 @@ +import { AGENT_ENV, SYSTEM_NAME, getPrefix } from "./util"; +import { + ChangeMessageVisibilityCommand, + ChangeMessageVisibilityCommandInput, + ChangeMessageVisibilityCommandOutput, + DeleteMessageCommand, + DeleteMessageCommandInput, + DeleteMessageCommandOutput, + GetQueueAttributesCommand, + GetQueueAttributesCommandInput, + GetQueueAttributesCommandOutput, + MessageAttributeValue, + ReceiveMessageCommand, + ReceiveMessageCommandInput, + ReceiveMessageCommandOutput, + SQSClient, + Message as SQSMessage, + SendMessageCommand, + SendMessageCommandInput, + SendMessageCommandOutput +} from "@aws-sdk/client-sqs"; +import { LogLevel, log } from "./log"; +import { SqsQueueType } from "../../types"; + +export const QUEUE_URL_TEST: Map = new Map(); +export const QUEUE_URL_SCALE_IN: Map = new Map(); +export let QUEUE_URL_COMMUNICATION: string; +// Export for testing so we can reset sqs +export const config: { sqsClient: SQSClient } = { + sqsClient: undefined as unknown as SQSClient +}; + +// Put this in an init that runs later so we don't throw on start-up. +export function init () { + // Where is your application name, system name, and service name concatenated with underscores, capitalized, and all dashes replaced with underscores. + // The prefix for the injected keys would be the same as above since it is based upon the owning application's application and service names. + // TL;DR - Scale is owned by the pewpewagent(s) and we inject the environment variable AGENT_ENV to the controller as a string delimited array + + if (QUEUE_URL_TEST.size === 0) { + // Scale is owned by the pewpewagent and we inject the environment variable AGENT_ENV to the controller + // Except for the build environment. Don't set the AGENT_ENV there to get it to fall back to the APPLICATION_NAME code + if (AGENT_ENV) { + // Controller should have this env variable from the deployment + const systemNames: string[] = AGENT_ENV.split(","); + for (const systemName of systemNames) { + const PREFIX: string = "PEWPEWAGENT_" + systemName.toUpperCase().replace("-", "_"); + const queueUrlTest: string | undefined = process.env[`${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`]; + log(`${PREFIX}_SQS_SCALE_OUT_QUEUE_URL = ${queueUrlTest}`, LogLevel.DEBUG); + if (!queueUrlTest) { + log(`Could not load the environment variable ${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`); + } + QUEUE_URL_TEST.set(systemName, queueUrlTest); + } + } else { + // We're an Agent, it's ours! + const PREFIX: string = getPrefix(); + const queueUrlTest: string | undefined = process.env[`${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`]; + log(`${PREFIX}_SQS_SCALE_OUT_QUEUE_URL = ${queueUrlTest}`, LogLevel.DEBUG); + if (!queueUrlTest) { + log(`Could not load the environment variable ${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_SQS_SCALE_OUT_QUEUE_URL`); + } + QUEUE_URL_TEST.set(SYSTEM_NAME, queueUrlTest); + } + } + + if (QUEUE_URL_SCALE_IN.size === 0) { + // Scale is owned by the pewpewagent and we inject the environment variable AGENT_ENV to the controller + // Except for the build environment. Don't set the AGENT_ENV there to get it to fall back to the APPLICATION_NAME code + if (AGENT_ENV) { + // Controller should have this env variable from the deployment + const systemNames: string[] = AGENT_ENV.split(","); + for (const systemName of systemNames) { + const PREFIX: string = "PEWPEWAGENT_" + systemName.toUpperCase().replace("-", "_"); + const queueUrlTest: string | undefined = process.env[`${PREFIX}_SQS_SCALE_IN_QUEUE_URL`]; + log(`${PREFIX}_SQS_SCALE_IN_QUEUE_URL = ${queueUrlTest}`, LogLevel.DEBUG); + if (!queueUrlTest) { + log(`Could not load the environment variable ${PREFIX}_SQS_SCALE_IN_QUEUE_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_SQS_SCALE_IN_QUEUE_URL`); + } + QUEUE_URL_SCALE_IN.set(systemName, queueUrlTest); + } + } else { + // We're an Agent, it's ours! + const PREFIX: string = getPrefix(); + const queueUrlTest: string | undefined = process.env[`${PREFIX}_SQS_SCALE_IN_QUEUE_URL`]; + log(`${PREFIX}_SQS_SCALE_IN_QUEUE_URL = ${queueUrlTest}`, LogLevel.DEBUG); + if (!queueUrlTest) { + log(`Could not load the environment variable ${PREFIX}_SQS_SCALE_IN_QUEUE_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX}_SQS_SCALE_IN_QUEUE_URL`); + } + QUEUE_URL_SCALE_IN.set(SYSTEM_NAME, queueUrlTest); + } + } + + if (!QUEUE_URL_COMMUNICATION) { + // Communication is owned by the pewpewcontroller and we inject the environment variable CONTROLLER_ENV to the agents and the controller + // Except for the build environment. Don't set the CONTROLLER_ENV there to get it to fall back to the APPLICATION_NAME code + const PREFIX_COMM: string = getPrefix(true); // Use the controller if we have one + const queueUrlCommunication: string | undefined = process.env[`${PREFIX_COMM}_SQS_COMMUNICATION_QUEUE_URL`]; + log(`${PREFIX_COMM}_SQS_COMMUNICATION_QUEUE_URL = ${queueUrlCommunication}`, LogLevel.DEBUG); + if (!queueUrlCommunication) { + log(`Could not load the environment variable ${PREFIX_COMM}_SQS_COMMUNICATION_QUEUE_URL`, LogLevel.ERROR); + throw new Error(`Could not load the environment variable ${PREFIX_COMM}_SQS_COMMUNICATION_QUEUE_URL`); + } + QUEUE_URL_COMMUNICATION = queueUrlCommunication; + } + + // Create an SQS service object + if (config.sqsClient as unknown === undefined) { + config.sqsClient = new SQSClient({ + region: "us-east-1" + }); + } +} + +let accessCallback: (date: Date) => void | undefined; + +export function setAccessCallback (fn: (date: Date) => void) { + accessCallback = fn; +} + +function callAccessCallback (date: Date) { + try { + if (accessCallback) { + accessCallback(date); + } else { + log("sqs setAccessCallback has not be set. Cannot call the accessCallback", LogLevel.WARN); + } + } catch (error: unknown) { + log("Calling the Access Callback (set last s3 accessed failed", LogLevel.ERROR, error); + } +} + +/** + * Retrieves the QueueUrl for the specified queue. Shared function for the other SQS functions. + * Exported for testing. + * @param sqsQueueType {SqsQueueType} to get the url for + * @param sqsQueueName {string} name of the queue if there is more than one (controller) + */ +export function getQueueUrl (sqsQueueType: SqsQueueType, sqsQueueName?: string): string { + init(); + let queueUrl: string | undefined; + log(`getQueueUrl(${sqsQueueType}, ${JSON.stringify(sqsQueueName)})`, LogLevel.DEBUG); + if (sqsQueueType === SqsQueueType.Communications) { + queueUrl = QUEUE_URL_COMMUNICATION; + } else if (sqsQueueType === SqsQueueType.Test && sqsQueueName) { + queueUrl = QUEUE_URL_TEST.get(sqsQueueName); + if (!queueUrl) { + throw new Error(`No such Queue found = ${sqsQueueName}`); + } + } else if (sqsQueueType === SqsQueueType.Scale && sqsQueueName) { + queueUrl = QUEUE_URL_SCALE_IN.get(sqsQueueName); + if (!queueUrl) { + throw new Error(`No such Queue found = ${sqsQueueName}`); + } + } else { + if (QUEUE_URL_TEST.size !== 1 || QUEUE_URL_SCALE_IN.size !== 1) { + log(`Only Agents with a single QUEUE_URL_TEST can getQueueUrl() without providing a queue name: QUEUE_URL_TEST.size=${QUEUE_URL_TEST.size}`, LogLevel.ERROR, QUEUE_URL_TEST); + throw new Error(`Only Agents with a single QUEUE_URL_TEST can getQueueUrl() without providing a queue name: QUEUE_URL_TEST.size=${QUEUE_URL_TEST.size}`); + } + queueUrl = (sqsQueueType === SqsQueueType.Scale ? QUEUE_URL_SCALE_IN : QUEUE_URL_TEST).values().next().value; + } + if (queueUrl === undefined) { + throw new Error(`Could not load queueUrl. Unknown Error for SqsQueueType ${SqsQueueType}, sqsQueueName ${sqsQueueName}`); + } + log(`getQueueUrl(${sqsQueueType}, ${JSON.stringify(sqsQueueName)}) = ${queueUrl}`, LogLevel.DEBUG); + return queueUrl; +} + +/** + * Agent: Retrieves a new test to run or returns undefined + * @returns SQSMessage or undefined + */ +export async function getNewTestToRun (): Promise { + init(); + // If you try to call this from a controller (that has multiple queues) we should throw. + if (QUEUE_URL_TEST.size !== 1) { + log(`Only Agents with a single QUEUE_URL_TEST can getNewTestsToRun(): QUEUE_URL_TEST.size=${QUEUE_URL_TEST.size}`, LogLevel.ERROR, QUEUE_URL_TEST); + throw new Error(`Only Agents with a single QUEUE_URL_TEST can getNewTestsToRun(): QUEUE_URL_TEST.size=${QUEUE_URL_TEST.size}`); + } + const queueUrlTest = QUEUE_URL_TEST.values().next().value; + const params: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: queueUrlTest, + VisibilityTimeout: 60, + WaitTimeSeconds: 20 + }; + const result: ReceiveMessageCommandOutput = await receiveMessage(params); + return (result && Array.isArray(result.Messages) && result.Messages.length > 0) ? result.Messages[0] : undefined; +} + +/** + * Controller: Retrieves a communcations message or returns undefined + * @returns SQSMessage or undefined + */ +export async function getCommunicationMessage (): Promise { + init(); + const params: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: QUEUE_URL_COMMUNICATION, + VisibilityTimeout: 20, + WaitTimeSeconds: 20 + }; + const result: ReceiveMessageCommandOutput = await receiveMessage(params); + return (result && Array.isArray(result.Messages) && result.Messages.length > 0) ? result.Messages[0] : undefined; +} + +/** + * Controller: Sends a new test to the Test Queue (to scale up and start a new test) + * @param messageAttributes {Record} message body with test properties + * @param sqsQueueName {string} name of the sqs queue + * @returns messageId {string} + */ +export async function sendNewTestToRun (messageAttributes: Record, sqsQueueName: string): Promise { + init(); + const messageKeys = Object.keys(messageAttributes); + if (messageKeys.length === 0) { + throw new Error("You must specify at least one Message Attribute"); + } + if (!sqsQueueName || !QUEUE_URL_TEST.has(sqsQueueName)) { + throw new Error("You must specify a valid sqsQueueName: " + sqsQueueName); + } + const queueUrlTest: string = QUEUE_URL_TEST.get(sqsQueueName)!; + try { + const yamlFile: string | undefined = messageKeys.includes("YamlFile") ? messageAttributes["YamlFile"].StringValue : undefined; + const sqsMessageRequest: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: yamlFile ? `Launching the ${yamlFile} test on the ${sqsQueueName} Test Queue` : `Sending Message to the ${sqsQueueName} Test Queue`, + QueueUrl: queueUrlTest + }; + log("sendNewTestToRun request", LogLevel.DEBUG, Object.assign({}, sqsMessageRequest, { MessageAttributes: undefined })); + const result: SendMessageCommandOutput = await sendMessage(sqsMessageRequest); + log(`sendNewTestToRun result.MessageId: ${result.MessageId}`, LogLevel.DEBUG); + return result.MessageId; + } catch (error: unknown) { + log("Could not send new Test to Run Message", LogLevel.ERROR, error); + throw error; + } +} + +/** + * Agent: Sends a message to the communications queue for the controller + * @param messageAttributes {Record} message body with test status properties + * @returns messageId {string} + */ +export async function sendNewCommunicationsMessage (messageAttributes: Record): Promise { + init(); + const messageKeys = Object.keys(messageAttributes); + if (messageKeys.length === 0) { + throw new Error("You must specify at least one Message Attribute"); + } + try { + const message: string | undefined = messageKeys.includes("Message") ? messageAttributes["Message"].StringValue : undefined; + const sqsMessageRequest: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: message || "Sending Message to the Communications Queue", + QueueUrl: QUEUE_URL_COMMUNICATION + }; + log("sendNewCommunicationsMessage request", LogLevel.DEBUG, Object.assign({}, sqsMessageRequest, { MessageAttributes: undefined })); + const result: SendMessageCommandOutput = await sendMessage(sqsMessageRequest); + log(`sendNewCommunicationsMessage result.MessageId: ${result.MessageId}`, LogLevel.DEBUG); + return result.MessageId; + } catch (error: unknown) { + log("Could not send communications Message", LogLevel.ERROR, error); + throw error; + } +} + +export interface MessageByHandleOptions { + /** message Handle from a get message call */ + messageHandle: string; + /** which queue the message is on */ + sqsQueueType: SqsQueueType; + /** name of the queue. optional for the agent, required for the controller (non-communication) */ + sqsQueueName?: string; +} + +/** + * Controller or Agent: Deletes a message from the SQS queue + * @param messageHandle {string} message Handle from a get message call + * @param sqsQueueType {SqsQueueType} which queue the message is on + * @param sqsQueueName {string} name of the queue. optional for the agent, required for the controller (non-communication) + */ +export function deleteMessageByHandle ({ messageHandle, sqsQueueType, sqsQueueName }: MessageByHandleOptions): Promise { + if (sqsQueueType === undefined) { + throw new Error("sqsQueueType must be provided"); + } + init(); + const queueUrl: string = getQueueUrl(sqsQueueType, sqsQueueName); + const params: DeleteMessageCommandInput = { + QueueUrl: queueUrl, + ReceiptHandle: messageHandle + }; + return deleteMessage(params); +} + +/** + * Controller or Agent: Extends the visibility lockout of a message from the SQS queue + * @param messageHandle {string} message Handle from a get message call + * @param sqsQueueType {SqsQueueType} which queue the message is on + * @param sqsQueueName {string} name of the queue. optional for the agent, required for the controller (non-communication) + */ + export function changeMessageVisibilityByHandle ({ messageHandle, sqsQueueType, sqsQueueName }: MessageByHandleOptions): Promise { + if (sqsQueueType === undefined) { + throw new Error("sqsQueueType must be provided"); + } + init(); + const queueUrl: string = getQueueUrl(sqsQueueType, sqsQueueName); + const params: ChangeMessageVisibilityCommandInput = { + QueueUrl: queueUrl, + VisibilityTimeout: 60, + ReceiptHandle: messageHandle + }; + return changeMessageVisibility(params); +} + +/** + * Controller: Gets the size and status of the queue + * @param sqsQueueType {SqsQueueType} which queue the message is on + * @param sqsQueueName {string} name of the queue. required for the non-communication queues + */ +export async function getQueueAttributesMap (sqsQueueType: SqsQueueType, sqsQueueName?: string): Promise | undefined> { + init(); + const queueUrl: string = getQueueUrl(sqsQueueType, sqsQueueName); + const params: GetQueueAttributesCommandInput = { + QueueUrl: queueUrl, + AttributeNames: ["All"] + }; + const result: GetQueueAttributesCommandOutput = await getQueueAttributes(params); + return result.Attributes; +} + +/** + * Agent: Gets a message off the scale in queue or returns undefined + * @returns SQSMessage or undefined + */ +export async function getTestScalingMessage (): Promise { + init(); + // If you try to call this from a controller (that has multiple queues) we should throw. + if (QUEUE_URL_SCALE_IN.size !== 1) { + log(`Only Agents with a single QUEUE_URL_SCALE_IN can getTestScalingMessage(): QUEUE_URL_SCALE_IN.size=${QUEUE_URL_SCALE_IN.size}`, LogLevel.ERROR, QUEUE_URL_SCALE_IN); + throw new Error(`Only Agents with a single QUEUE_URL_SCALE_IN can getTestScalingMessage(): QUEUE_URL_SCALE_IN.size=${QUEUE_URL_SCALE_IN.size}`); + } + const queueUrlScale = QUEUE_URL_SCALE_IN.values().next().value; + const params: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: queueUrlScale, + VisibilityTimeout: 30, + WaitTimeSeconds: 5 + }; + const result: ReceiveMessageCommandOutput = await receiveMessage(params); + return (result && Array.isArray(result.Messages) && result.Messages.length > 0) ? result.Messages[0] : undefined; +} + +/** + * Controller or Agent: Sends a message to the Test Scaling Queue (to prevent scale ins) + * @param sqsQueueName {string} name of the queue. must be provided by the controller, not needed by agents + * @returns messageId {string} + */ +export async function sendTestScalingMessage (sqsQueueName?: string): Promise { + init(); + const messageAttributes: Record = { + Scale: { + DataType: "String", + StringValue: "test" + } + }; + let queueUrlScale: string = QUEUE_URL_SCALE_IN.values().next().value; + if (QUEUE_URL_SCALE_IN.size !== 1) { + if (!sqsQueueName || !QUEUE_URL_SCALE_IN.has(sqsQueueName)) { + throw new Error("You must specify a valid sqsQueueName: " + sqsQueueName); + } + queueUrlScale = QUEUE_URL_SCALE_IN.get(sqsQueueName)!; + } else { + sqsQueueName = QUEUE_URL_SCALE_IN.keys().next().value; + } + try { + const sqsMessageRequest: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: `Sending Message to the ${sqsQueueName} Test Scaling Queue`, + QueueUrl: queueUrlScale + }; + log("sendTestScalingMessage request", LogLevel.DEBUG, sqsMessageRequest); + const result: SendMessageCommandOutput = await sendMessage(sqsMessageRequest); + log(`sendTestScalingMessage result.MessageId: ${result.MessageId}`, LogLevel.DEBUG); + return result.MessageId; + } catch (error: unknown) { + log("Could not send new Test Scaling Message", LogLevel.ERROR, error); + throw error; + } +} + +/** + * Agent: Helper function to get/delete and send a new message to the scaling queue (keep alive) + */ +export async function refreshTestScalingMessage (): Promise { + init(); + try { + const scalingMessage: SQSMessage | undefined = await getTestScalingMessage(); + log("refreshTestScalingMessage getTestScalingMessage scalingMessage", LogLevel.DEBUG, scalingMessage); + // Send a new one regardless of whether we have an old one. We need to keep ourselves "alive" + const messageId: string | undefined = await sendTestScalingMessage(); + log(`refreshTestScalingMessage sendTestScalingMessage messageId: ${messageId}`, LogLevel.DEBUG); + // Delete the old one after we send the new one (in case the send fails) + if (scalingMessage && scalingMessage.ReceiptHandle) { + await deleteMessageByHandle({ messageHandle: scalingMessage.ReceiptHandle, sqsQueueType: SqsQueueType.Scale }); + log("refreshTestScalingMessage old scalingMessage deleted", LogLevel.DEBUG, scalingMessage); + } else { + log("refreshTestScalingMessage did not find an existing scaling message", LogLevel.WARN, scalingMessage); + } + return messageId; + } catch (error: unknown) { + log("Could not refresh Test Scaling Message", LogLevel.ERROR, error); + throw error; + } +} + +/** + * Agent: Helper function to delete a message from the scaling queue (test finished) + */ +export async function deleteTestScalingMessage (): Promise { + init(); + try { + const scalingMessage: SQSMessage | undefined = await getTestScalingMessage(); + log("deleteTestScalingMessage getTestScalingMessage scalingMessage", LogLevel.DEBUG, scalingMessage); + // Delete it but don't error if we can't find one + if (scalingMessage && scalingMessage.ReceiptHandle) { + await deleteMessageByHandle({ messageHandle: scalingMessage.ReceiptHandle, sqsQueueType: SqsQueueType.Scale }); + log("deleteTestScalingMessage old scalingMessage deleted", LogLevel.DEBUG, scalingMessage); + } else { + log("deleteTestScalingMessage did not find an existing scaling message", LogLevel.WARN, scalingMessage); + } + return scalingMessage && scalingMessage.MessageId; + } catch (error: unknown) { + log("Could not delete a Test Scaling Message", LogLevel.ERROR, error); + throw error; + } +} + +// Export for testing +export async function receiveMessage (params: ReceiveMessageCommandInput): Promise { + init(); + try { + log("receiveMessage request", LogLevel.DEBUG, params); + const result: ReceiveMessageCommandOutput = await config.sqsClient.send(new ReceiveMessageCommand(params)); + log("receiveMessage succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("receiveMessage failed", LogLevel.ERROR, error); + throw error; + } +} + +// Export for testing +export async function sendMessage (params: SendMessageCommandInput): Promise { + init(); + try { + log("sendMessage request", LogLevel.DEBUG, params); + const result: SendMessageCommandOutput = await config.sqsClient.send(new SendMessageCommand(params)); + log("sendMessage succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("sendMessage failed", LogLevel.ERROR, error); + throw error; + } +} + +// Export for testing +export async function deleteMessage (params: DeleteMessageCommandInput): Promise { + init(); + try { + log("deleteMessage request", LogLevel.DEBUG, params); + const result: DeleteMessageCommandOutput = await config.sqsClient.send(new DeleteMessageCommand(params)); + log("deleteMessage succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return; + } catch (error: unknown) { + log("deleteMessage failed", LogLevel.ERROR, error); + throw error; + } +} + +// Export for testing +export async function changeMessageVisibility (params: ChangeMessageVisibilityCommandInput): Promise { + init(); + try { + log("changeMessageVisibility request", LogLevel.DEBUG, params); + const result: ChangeMessageVisibilityCommandOutput = await config.sqsClient.send(new ChangeMessageVisibilityCommand(params)); + log("changeMessageVisibility succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return; + } catch (error: unknown) { + log("changeMessageVisibility failed", LogLevel.ERROR, error); + throw error; + } +} + +export async function getQueueAttributes (params: GetQueueAttributesCommandInput): Promise { + init(); + try { + log("getQueueAttributes request", LogLevel.DEBUG, params); + const result: GetQueueAttributesCommandOutput = await config.sqsClient.send(new GetQueueAttributesCommand(params)); + log("getQueueAttributes succeeded", LogLevel.DEBUG, result); + callAccessCallback(new Date()); // Update the last timestamp + return result; + } catch (error: unknown) { + log("getQueueAttributes failed", LogLevel.ERROR, error); + throw error; + } +} + +export async function cleanUpQueue (sqsQueueType: SqsQueueType, sqsQueueName?: string | undefined): Promise { + init(); + const QueueUrl: string = getQueueUrl(sqsQueueType, sqsQueueName); + const receiveMessageParams: ReceiveMessageCommandInput = { + AttributeNames: ["All"], + MaxNumberOfMessages: 1, + MessageAttributeNames: ["All"], + QueueUrl, + VisibilityTimeout: 1, + WaitTimeSeconds: 1 + }; + let count = 0; + try { + let messageReceived: SQSMessage | undefined; + do { + const result: ReceiveMessageCommandOutput = await receiveMessage(receiveMessageParams); + log(QueueUrl + " receiveMessage: " + result, LogLevel.DEBUG, result); + messageReceived = (result && Array.isArray(result.Messages) && result.Messages.length > 0) ? result.Messages[0] : undefined; + log(QueueUrl + " getTestScalingMessage messageReceived: " + messageReceived, LogLevel.DEBUG, messageReceived); + if (messageReceived && messageReceived.ReceiptHandle) { + await deleteMessageByHandle({ messageHandle: messageReceived.ReceiptHandle, sqsQueueType, sqsQueueName }).then(() => { + count++; + log(QueueUrl + " deleteMessageByHandle deleted: " + messageReceived!.ReceiptHandle, LogLevel.DEBUG, { sqsQueueType, sqsQueueName, count }); + }).catch((error) => log(QueueUrl + " deleteMessageByHandle error: " + messageReceived!.ReceiptHandle, LogLevel.WARN, error, { sqsQueueType, sqsQueueName })); + } + } while (messageReceived !== undefined); + } catch (error: unknown) { + log(QueueUrl + ": Error cleaning up the scaling queue", LogLevel.ERROR, error); + } + return count; +} + +export async function cleanUpQueues (): Promise { + const results: number[] = await Promise.all( + Array.from(Object.values(SqsQueueType)) + .map((sqsScalingQueueType: SqsQueueType) => cleanUpQueue(sqsScalingQueueType)) + ); + const total = results.reduce((prev: number, current: number) => prev + current, 0); + log("cleanUpQueues deleted: " + results, LogLevel.DEBUG, { results, total }); + return total; +} diff --git a/common/src/util/util.ts b/common/src/util/util.ts new file mode 100644 index 00000000..7c2dcbb6 --- /dev/null +++ b/common/src/util/util.ts @@ -0,0 +1,111 @@ +import * as _fs from "fs/promises"; +import * as os from "os"; +import { LogLevel, log } from "./log"; + +export const APPLICATION_NAME: string = process.env.APPLICATION_NAME || "pewpewagent"; +export const CONTROLLER_APPLICATION_NAME: string = process.env.CONTROLLER_APPLICATION_NAME || "pewpewcontroller"; +export const CONTROLLER_APPLICATION_PREFIX: string = CONTROLLER_APPLICATION_NAME.toUpperCase().replace(/-/g, "_") + "_"; +export const SYSTEM_NAME: string = process.env.SYSTEM_NAME || "unittests"; +export const CONTROLLER_ENV = process.env.CONTROLLER_ENV; +export const AGENT_ENV = process.env.AGENT_ENV; + +/** This applications PREFIX. No overrides */ +export const PREFIX_DEFAULT: string = `${APPLICATION_NAME}-${SYSTEM_NAME}`.toUpperCase().replace(/-/g, "_"); +let PREFIX_CONTROLLER_ENV: string | undefined; +/** + * Returns the Environment variable Prefix for S3 and SQS. if controllerEnv == true, + * uses the process.env.CONTROLLER_ENV + * @param controllerEnv (Optional). If provided uses this for the controller environment variable. process.env.CONTROLLER_ENV + * @returns {string} Environment Variable Prefix + */ +export const getPrefix = (controllerEnv?: boolean | string): string => { + if (controllerEnv) { + if (typeof controllerEnv === "string") { + return (CONTROLLER_APPLICATION_PREFIX + controllerEnv.toUpperCase().replace(/-/g, "_")); + } + if (!PREFIX_CONTROLLER_ENV) { + PREFIX_CONTROLLER_ENV = CONTROLLER_ENV + ? (CONTROLLER_APPLICATION_PREFIX + CONTROLLER_ENV.toUpperCase().replace(/-/g, "_")) + : PREFIX_DEFAULT; + } + return PREFIX_CONTROLLER_ENV; + } + return PREFIX_DEFAULT; +}; + +/** @deprecated Use `fs/promises` */ +export const fs = { + /** @deprecated Use `fs/promises` */ + access: _fs.access, + /** @deprecated Use `fs/promises` */ + chmod: _fs.chmod, + /** @deprecated Use `fs/promises` */ + mkdir: _fs.mkdir, + /** @deprecated Use `fs/promises` */ + readdir: _fs.readdir, + /** @deprecated Use `fs/promises` */ + readFile: _fs.readFile, + /** @deprecated Use `fs/promises` */ + rename: _fs.rename, + /** @deprecated Use `fs/promises` */ + unlink: _fs.unlink, + /** @deprecated Use `rimraf` or `fs/promises` */ + rmdir: _fs.rmdir, + /** @deprecated Use `fs/promises` */ + stat: _fs.stat +}; + +export async function sleep (ms: number): Promise { + try { + await new Promise((resolve) => setTimeout(resolve, ms)); + } catch (error: unknown) { + log("sleep Error", LogLevel.ERROR, error); // swallow it + } +} + +// Waits waits for up to ms (milliseconds) polling every 100ms for fn() to return truthy +export async function poll (fn: () => Promise, ms: number, timeoutCb?: (errMsg: string) => string): Promise { + const startTime: number = Date.now(); + const endTime: number = startTime + ms; + // Default the interval to 100ms + const interval = 100; + let counter: number = 0; + while (Date.now() < endTime) { + // If the loop would take us past the endTime, use endTime + 1 + const endLoop: number = Math.min(startTime + (interval * (++counter)), endTime + 1); + const result: T = await fn(); + if (result) { + return result; // Return as soon we get a result; + } + if (Date.now() < endLoop) { + await sleep(endLoop - Date.now()); + } + } + let errorMsg: string = `Promise timed out after ${ms}ms.`; + if (timeoutCb) { + errorMsg = timeoutCb(errorMsg); + } + throw new Error(errorMsg); +} + +export function getLocalIpAddress (ipv: 4 | 6 = 4) { + const ipVersion = "IPv" + ipv; + const networkInterfaces = os.networkInterfaces(); + // We don't care about order and need to break out early + for (const addresses of Object.values(networkInterfaces)) { + for (const interfaceAddress of addresses || []) { + if (!interfaceAddress.internal && interfaceAddress.address && interfaceAddress.family === ipVersion) { + return interfaceAddress.address; + } + } + } + // eslint-disable-next-line no-console + console.error( + `Computer does not have an external networkInterface: ${ipVersion}\nnetworkInterfaces: ${JSON.stringify(networkInterfaces)}` + ); + return os.hostname(); +} + +export function createStatsFileName (testId: string, iteration?: number): string { + return `stats-${testId}${iteration ? `-${iteration}` : ""}.json`; +} diff --git a/common/src/yamlparser.ts b/common/src/yamlparser.ts new file mode 100644 index 00000000..21ce3b5b --- /dev/null +++ b/common/src/yamlparser.ts @@ -0,0 +1,182 @@ +import { LogLevel, log, config as logConfig } from "./util/log"; +import { Config } from "@fs/config-wasm"; +import { EnvironmentVariables } from "../types"; +import { readFile } from "fs/promises"; + +export function parseEnvVarFromError (error: any): string | undefined { + if (typeof error === "string") { + // Format is IndexingIntoJson\("VARIABLE", Null) + const match = error.match(/MissingEnvironmentVariable\("([^"]*)", Marker/); + log("parseYamlFile match: " + JSON.stringify(match), LogLevel.DEBUG, match); + if (match && match.length > 1) { + const expectedVariable = match[1]; + log("parseYamlFile missing variable: " + expectedVariable, LogLevel.DEBUG); + return expectedVariable; + } + } + return undefined; +} + +export function parseDurationFromError (error: any): string | undefined { + if (typeof error === "string") { + // Format is IndexingIntoJson\("VARIABLE", Null) + const match = error.match(/InvalidDuration\("([^"]*)"/); + log("parseYamlFile match: " + JSON.stringify(match), LogLevel.DEBUG, match); + if (match && match.length > 1) { + const duration = match[1]; + log("parseYamlFile InvalidDuration: " + duration, LogLevel.DEBUG); + return duration; + } + } + return undefined; +} + +export function parsePeakLoadFromError (error: any): string | undefined { + if (typeof error === "string") { + // Format is IndexingIntoJson\("VARIABLE", Null) + const match = error.match(/InvalidPeakLoad\("([^"]*)"/); + log("parseYamlFile match: " + JSON.stringify(match), LogLevel.DEBUG, match); + if (match && match.length > 1) { + const peakLoad = match[1]; + log("parseYamlFile InvalidPeakLoad: " + peakLoad, LogLevel.DEBUG); + return peakLoad; + } + } + return undefined; +} + +export class YamlParser { + protected bucketSizeMs: number; + protected testRunTimeMn: number; + protected inputFileNames: string[]; + protected loggerFileNames: string[]; + + private constructor (bucketSizeMs: number, + testRunTimeMn: number, + inputFileNames: string[], + loggerFileNames: string[]) { + this.bucketSizeMs = bucketSizeMs; + this.testRunTimeMn = testRunTimeMn; + this.inputFileNames = inputFileNames; + this.loggerFileNames = loggerFileNames; + } + + public static async parseYamlFile (filepath: string, environmentVariables: EnvironmentVariables): Promise { + let config: Config | undefined; + try { + const fileBuffer: Buffer = await readFile(filepath); + const varMap: Map = new Map(); + for (const [key, value] of Object.entries(environmentVariables)) { + varMap.set(key, value); + } + let yamlValid = false; + let counter = 0; + const missingVariables: EnvironmentVariables = {}; + // If we're missing variables, Parse them all by passing dummy values in. + do { + counter++; + try { + // Pass these into the constructor for validation that we have them all + config = new Config( + fileBuffer, + varMap, + typeof logConfig.LoggingLevel === "number" ? undefined : logConfig.LoggingLevel + // TODO: Add version checker + ); + yamlValid = true; + } catch (error: unknown) { + log("Could not parse yaml file: " + filepath, LogLevel.DEBUG, error); + if (counter > 50) { + throw error; + } + // See if we're missing a variable + const missingVariable: string | undefined = parseEnvVarFromError(error); + const badDuration: string | undefined = parseDurationFromError(error); + const badPeakLoad: string | undefined = parsePeakLoadFromError(error); + log("missingVariable: " + missingVariable, LogLevel.DEBUG); + log("badDuration: " + badDuration, LogLevel.DEBUG); + log("badPeakLoad: " + badPeakLoad, LogLevel.DEBUG); + if (missingVariable) { + // Add it to the list and the varMap and retry + // Use a number since it will work even for strings. + // Possible values: string, number, duration, peak_load + missingVariables[missingVariable] = "" + counter; + varMap.set(missingVariable, missingVariables[missingVariable]); // We have to use a duration here in case they ask for a + } else if (typeof error === "string" && badDuration && Object.values(missingVariables).includes(badDuration)) { + // We accidentally stuck a random number in a duration field. Find which one it was + const entry: [string, string] | undefined = Object.entries(missingVariables).find((missingVar: [string, string]) => missingVar[1] === badDuration); + log("entry: " + entry, LogLevel.DEBUG); + if (entry) { + // Change it to a duration + missingVariables[entry[0]] = entry[1] + "m"; + varMap.set(entry[0], missingVariables[entry[0]]); + log("missingVariables after: " + missingVariables, LogLevel.DEBUG, missingVariables); + } else { + // It wasn't one of our variables that caused the issue + throw error; + } + } else if (typeof error === "string" && badPeakLoad && Object.values(missingVariables).includes(badPeakLoad)) { + // We accidentally stuck a random number in a peak_load field. Find which one it was + const entry: [string, string] | undefined = Object.entries(missingVariables).find((missingVar: [string, string]) => missingVar[1] === badPeakLoad); + log("entry: " + entry, LogLevel.DEBUG); + if (entry) { + // Change it to a peak_load + missingVariables[entry[0]] = entry[1] + "hpm"; + varMap.set(entry[0], missingVariables[entry[0]]); + log("missingVariables after: " + missingVariables, LogLevel.DEBUG, missingVariables); + } else { + // It wasn't one of our variables that caused the issue + throw error; + } + } else { + // It's something else, throw it and let the final catch return it. + throw error; + } + } + } while (!yamlValid); + // typescript can't figure out that config won't be null here, add a null check + if (Object.keys(missingVariables).length > 0 || !config) { + throw new Error("missingEnvironmentVariables=" + Object.keys(missingVariables)); + } + + // Just let this throw. There's no reason to let a config parse happen if the checkOk fails + config.checkOk(); + + // BucketSize comes in as seconds, convert it to MS for the agents + const bucketSizeMs: number = Number(config.getBucketSize()) * 1000; + // testRunTime comes in as seconds, convert it to minutes for the agents + const testRunTimeMn: number = Math.round(Number(config.getDuration()) / 60); + const inputFiles: string[] = config.getInputFiles(); + const loggerFiles: string[] = config.getLoggerFiles(); + log(`parseYamlFile(${filepath})`, LogLevel.DEBUG, { bucketSizeMs, testRunTimeMn, inputFiles, loggerFiles }); + const yamlParser = new YamlParser(bucketSizeMs, testRunTimeMn, inputFiles, loggerFiles); + return yamlParser; + } catch (error: unknown) { + // We don't want to log a lot of errors to splunk, but it could be helpful to debug + log("Could not parse yaml file: " + filepath, LogLevel.WARN, error); + throw error; + } finally { + if (config) { + config.free(); + } + } + } + + public getBucketSizeMs (): number { + return this.bucketSizeMs; + } + + public getTestRunTimeMn (): number { + return this.testRunTimeMn; + } + + public getInputFileNames (): string[] { + return this.inputFileNames; + } + + public getLoggerFileNames (): string[] { + return this.loggerFileNames; + } +} + +export default YamlParser; diff --git a/common/test/basic.yaml b/common/test/basic.yaml new file mode 100644 index 00000000..0e3f0996 --- /dev/null +++ b/common/test/basic.yaml @@ -0,0 +1,25 @@ +load_pattern: + - linear: + from: 1% + to: 100% + over: 1m + - linear: + from: 100% + to: 100% + over: 1m +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://127.0.0.1:8080/healthcheck + peak_load: 30hpm diff --git a/common/test/basicheadersall.yaml b/common/test/basicheadersall.yaml new file mode 100755 index 00000000..6b2edade --- /dev/null +++ b/common/test/basicheadersall.yaml @@ -0,0 +1,32 @@ +load_pattern: + - linear: + from: 1% + to: 100% + over: 1m + - linear: + from: 100% + to: 100% + over: 1m +config: + client: + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +providers: + startProvider: + response: {} + endProvider: + response: {} +endpoints: + - method: GET + url: http://127.0.0.1:8000/healthcheck + peak_load: 30hpm + provides: + startProvider: + select: response.headers_all + where: response.status == 200 diff --git a/common/test/basicnopeakload.yaml b/common/test/basicnopeakload.yaml new file mode 100644 index 00000000..c35d0ad7 --- /dev/null +++ b/common/test/basicnopeakload.yaml @@ -0,0 +1,44 @@ +load_pattern: + - linear: + from: 1% + to: 100% + over: 1m + - linear: + from: 100% + to: 100% + over: 1m +config: + client: + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +providers: + startProvider: + response: {} + endProvider: + response: {} +endpoints: + - method: GET + url: http://127.0.0.1:8080/healthcheck + provides: + startProvider: + select: response.body + where: response.status == 200 + - method: GET + url: http://127.0.0.1:8080/healthcheck + headers: + StartProvider: ${startProvider} + peak_load: 30hpm + provides: + endProvider: + select: response.body + where: response.status == 200 + - method: GET + url: http://127.0.0.1:8080/healthcheck + headers: + EndProvider: ${endProvider} diff --git a/common/test/basicwithenv.yaml b/common/test/basicwithenv.yaml new file mode 100644 index 00000000..157ace1f --- /dev/null +++ b/common/test/basicwithenv.yaml @@ -0,0 +1,32 @@ +vars: + rampTime: 1m + loadTime: 1m + totalTime: 2m + serviceUrlAgent: ${SERVICE_URL_AGENT} + test: ${parseInt("${TEST}")} +load_pattern: + - linear: + from: 1% + to: 100% + over: ${rampTime} + - linear: + from: 100% + to: 100% + over: ${loadTime} +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + test: ${test} + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://${serviceUrlAgent}/healthcheck + peak_load: 30hpm diff --git a/common/test/basicwithfiles.yaml b/common/test/basicwithfiles.yaml new file mode 100644 index 00000000..01522b90 --- /dev/null +++ b/common/test/basicwithfiles.yaml @@ -0,0 +1,59 @@ +vars: + rampTime: 1m + loadTime: 1m + totalTime: 2m + logDir: ${SPLUNK_PATH} +load_pattern: + - linear: + from: 1% + to: 100% + over: ${rampTime} + - linear: + from: 100% + to: 100% + over: ${loadTime} +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +loggers: + remote_logger: + select: + timestamp: epoch("ms") + request: request["start-line"] + method: request.method + url: request.url + response: response["start-line"] + status: response.status + where: response.status >= 400 + limit: 1000 + to: '${logDir}/http-err-${epoch("ms")}.json' + pretty: false + local_logger: + select: '`${request["start-line"]},${response["start-line"]},${request.method},${response.status}`' + where: response.status >= 400 + limit: 1000 + to: 'errors.csv' + pretty: false +providers: + sessionId: + response: + # buffer: 100 + auto_return: force + localFile: + file: + path: 's3test.txt' + repeat: true + random: true +endpoints: + - method: GET + url: http://127.0.0.1:8080/healthcheck + peak_load: 30hpm diff --git a/common/test/basicwithvars.yaml b/common/test/basicwithvars.yaml new file mode 100644 index 00000000..c3f3fce9 --- /dev/null +++ b/common/test/basicwithvars.yaml @@ -0,0 +1,30 @@ +vars: + rampTime: 1m + loadTime: 1m + totalTime: 2m + serviceUrlAgent: 127.0.0.1:8080 +load_pattern: + - linear: + from: 1% + to: 100% + over: ${rampTime} + - linear: + from: 100% + to: 100% + over: ${loadTime} +config: + client: + # request_timeout: { secs: 10, nanos: 0 } + # request_timeout: 10s + headers: + TestTime: '${epoch("ms")}' + Accept: application/json + FS-User-Agent-Chain: PPAAS-Agent-Performance Test + User-Agent: FS-QA-SystemTest PPAAS Agent Performance Test + general: + bucket_size: 1m + log_provider_stats: 1m +endpoints: + - method: GET + url: http://${serviceUrlAgent}/healthcheck + peak_load: 30hpm diff --git a/common/test/empty.yaml b/common/test/empty.yaml new file mode 100644 index 00000000..e69de29b diff --git a/common/test/log.spec.ts b/common/test/log.spec.ts new file mode 100644 index 00000000..49ec7324 --- /dev/null +++ b/common/test/log.spec.ts @@ -0,0 +1,138 @@ +import { LogLevel, log } from "../src/index"; +import { expect } from "chai"; + +describe("Log", () => { + describe("Testing Log Levels", () => { + it("Should log at DEBUG", (done: Mocha.Done) => { + log("LogLevel.DEBUG", LogLevel.DEBUG); + done(); + }); + + it("Should log at INFO", (done: Mocha.Done) => { + log("LogLevel.INFO", LogLevel.INFO); + done(); + }); + + it("Should log at WARN", (done: Mocha.Done) => { + log("LogLevel.WARN", LogLevel.WARN); + done(); + }); + + it("Should log at ERROR", (done: Mocha.Done) => { + log("LogLevel.ERROR", LogLevel.ERROR); + done(); + }); + + it("Should log at FATAL", (done: Mocha.Done) => { + log("LogLevel.FATAL", LogLevel.FATAL); + done(); + }); + + it("bogus should log at INFO", (done: Mocha.Done) => { + log("LogLevel.bogus", "bogus" as any); + done(); + }); + }); + + describe("Testing Log Object", () => { + it("Should not log data if there's only a testId", (done: Mocha.Done) => { + const origObject = { testId: "onlytestId" }; + log("Only testId", LogLevel.INFO, origObject); + expect(origObject.testId).to.equal("onlytestId"); + done(); + }); + + it("Should not log data if there's only a yamlFile", (done: Mocha.Done) => { + const origObject = { yamlFile: "onlyyamlFile" }; + log("Only yamlFile", LogLevel.INFO, origObject); + expect(origObject.yamlFile).to.equal("onlyyamlFile"); + done(); + }); + + it("Should not log data if there's only a testId and yamlFile", (done: Mocha.Done) => { + log("TestId and YamlFile", LogLevel.INFO, { testId: "testId", yamlFile: "yamlFile" }); + done(); + }); + + it("Should log data if there's a testId", (done: Mocha.Done) => { + log("TestId and More", LogLevel.INFO, { testId: "testId", yamlFile: "yamlFile", other: "other", more: "more" }); + done(); + }); + + it("Should log string data", (done: Mocha.Done) => { + log("string data", LogLevel.INFO, "string data"); + done(); + }); + + it("Should log error", (done: Mocha.Done) => { + log("Only error", LogLevel.INFO, new Error("error message")); + done(); + }); + + it("Should log reject", (done: Mocha.Done) => { + Promise.reject("reject message").catch((reject) => { + log("Only reject", LogLevel.INFO, reject); + done(); + }); + }); + + it("Should log error stack", (done: Mocha.Done) => { + log("Error no message", LogLevel.INFO, new Error("")); + done(); + }); + + it("Should log reject stack", (done: Mocha.Done) => { + Promise.reject("").catch((reject) => { + log("", LogLevel.ERROR, reject); + done(); + }); + }); + + it("Should log stack", (done: Mocha.Done) => { + log("", LogLevel.ERROR); + done(); + }); + + it("Should log Map", (done: Mocha.Done) => { + const map = new Map([["key1", "value1"],["key2","value2"],["key3","value3"]]); + log("Only Map", LogLevel.INFO, map); + done(); + }); + + it("Should log string data and error", (done: Mocha.Done) => { + log("string data and error", LogLevel.INFO, "string data", new Error("error")); + done(); + }); + + it("Should log string data and testId object", (done: Mocha.Done) => { + log("string data and object", LogLevel.INFO, "string data", { testId: "testId", yamlFile: "yamlFile", other: "other", more: "more" }); + done(); + }); + + it("Should log string data, error, and testId object", (done: Mocha.Done) => { + log("string data, error, and object", LogLevel.INFO, "string data", new Error("error"), { testId: "testId", yamlFile: "yamlFile", other: "other", more: "more" }); + done(); + }); + + it("Should log string data, error, map, and testId object", (done: Mocha.Done) => { + const map = new Map([["key1", "value1"],["key2","value2"],["key3","value3"]]); + log("string data, error, Map, and object", LogLevel.INFO, "string data", new Error("error"), map, { testId: "testId", yamlFile: "yamlFile", other: "other", more: "more" }); + done(); + }); + + it("Should log string data, array, object, error, map, and testId object", (done: Mocha.Done) => { + const map = new Map([["key1", "value1"],["key2","value2"],["key3","value3"]]); + log( + "string data, array, object1, error, map, and object2", + LogLevel.INFO, + "string data", + ["array1", "array2"], + { other: "other1", more: "more1", different: "different", simLocation: null }, + new Error("error"), + map, + { testId: "testId", name: "name", other: "other2", more: "more2" } + ); + done(); + }); + }); +}); diff --git a/common/test/mock.ts b/common/test/mock.ts new file mode 100644 index 00000000..40fbfd12 --- /dev/null +++ b/common/test/mock.ts @@ -0,0 +1,333 @@ +import { AwsStub, mockClient } from "aws-sdk-client-mock"; +import { + ChangeMessageVisibilityCommand, + DeleteMessageCommand, + GetQueueAttributesCommand, + GetQueueAttributesCommandOutput, + MessageAttributeValue, + ReceiveMessageCommand, + ReceiveMessageCommandOutput, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + SQSClient, + SQSClientResolvedConfig, + Message as SQSMessage, + ServiceInputTypes as SQSServiceInputTypes, + ServiceOutputTypes as SQSServiceOutputTypes, + SendMessageCommand, + SendMessageCommandOutput +} from "@aws-sdk/client-sqs"; +import { + CompleteMultipartUploadCommand, + CompleteMultipartUploadCommandOutput, + CopyObjectCommand, + CopyObjectCommandOutput, + CreateMultipartUploadCommand, + DeleteObjectCommand, + GetObjectCommand, + GetObjectTaggingCommand, + GetObjectTaggingCommandOutput, + ListObjectsV2Command, + PutObjectCommand, + PutObjectTaggingCommand, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + S3Client, + S3ClientResolvedConfig, + _Object as S3Object, + ServiceInputTypes as S3ServiceInputTypes, + ServiceOutputTypes as S3ServiceOutputTypes, + Tag as S3Tag, + UploadPartCommand +} from "@aws-sdk/client-s3"; +import { LogLevel, log, s3, sqs, util } from "../src/index"; +import { Readable } from "stream"; +import { constants as bufferConstants } from "node:buffer"; +import { sdkStreamMixin } from "@aws-sdk/util-stream-node"; +const { MAX_STRING_LENGTH } = bufferConstants; + +const { + config: s3Config, + init: initS3 +} = s3; +const { + config: sqsConfig, + init: initSqs +} = sqs; + +export const UNIT_TEST_KEY_PREFIX: string = process.env.UNIT_TEST_KEY_PREFIX || "unittest"; +export const UNIT_TEST_FILENAME: string = process.env.UNIT_TEST_FILENAME || "s3test.txt"; +export const UNIT_TEST_FILEPATH: string = process.env.UNIT_TEST_FILEPATH || ("test/" + UNIT_TEST_FILENAME); +export const UNIT_TEST_LOCAL_FILE_LOCATION: string = process.env.UNIT_TEST_LOCAL_FILE_LOCATION || process.env.TEMP || "/tmp"; +export const MAX_POLL_WAIT: number = parseInt(process.env.MAX_POLL_WAIT || "0", 10) || 500; + +export let UNIT_TEST_BUCKET_NAME: string = process.env.UNIT_TEST_BUCKET_NAME || "my-test-bucket"; +export let UNIT_TEST_BUCKET_URL: string = `${UNIT_TEST_BUCKET_NAME}.s3.us-east-1.amazonaws.com`; +export let UNIT_TEST_KEYSPACE_PREFIX: string = "s3/pewpewcontroller-unittests-s3/"; +export const createLocation = (filename: string = UNIT_TEST_FILENAME, folder: string = UNIT_TEST_KEY_PREFIX) => + `https://${UNIT_TEST_BUCKET_URL}/${UNIT_TEST_KEYSPACE_PREFIX}${folder}/${filename}`; + + // ////////////////////////////////////////////// + // /////////// S3 ////////////////////////////// + // ////////////////////////////////////////////// + +let _mockedS3Instance: AwsStub | undefined; +export function mockS3 (): AwsStub { + if (_mockedS3Instance !== undefined) { + return _mockedS3Instance; + } + // _mockedS3Instance = mockClient(S3Client); + s3Config.s3Client = undefined as any; + initS3(); + UNIT_TEST_BUCKET_NAME = s3.BUCKET_NAME; + UNIT_TEST_BUCKET_URL = `${UNIT_TEST_BUCKET_NAME}.s3.us-east-1.amazonaws.com`; + UNIT_TEST_KEYSPACE_PREFIX = s3.KEYSPACE_PREFIX; + _mockedS3Instance = mockClient(s3Config.s3Client); + _mockedS3Instance.on(DeleteObjectCommand).resolves({}); + // There's no parameters, so just mock it + _mockedS3Instance.on(PutObjectTaggingCommand).resolves({}); + + return _mockedS3Instance; +} + +export function resetMockS3 (): void { + if (_mockedS3Instance !== undefined) { + _mockedS3Instance.reset(); + s3Config.s3Client = undefined as any; + _mockedS3Instance = undefined; + } +} + +export function mockListObject (filename: string, folder: string, lastModified: Date = new Date()) { + const s3Object: S3Object = { + Key: `${folder}/${filename}`, + LastModified: lastModified, + Size: 1, + StorageClass: "STANDARD" + }; + mockListObjects([s3Object]); +} + +export function mockListObjects (contents?: S3Object[] | undefined, truncated?: boolean) { + const mockedS3Instance: AwsStub = mockS3(); + mockedS3Instance.on(ListObjectsV2Command).resolves({ Contents: contents, IsTruncated: truncated }); +} + +export function mockUploadObject ({ filename = UNIT_TEST_FILENAME, folder = UNIT_TEST_KEY_PREFIX, duration }: { + filename?: string, + folder?: string, + duration?: number +} = {}): string { + const location = createLocation(filename, folder); + const ETag = "etag"; + const mockedUploadResult: Partial = { + Location: location, + ETag, + Bucket: UNIT_TEST_BUCKET_NAME, + Key: `${folder}/${filename}` + }; + log("mockUploadObject", LogLevel.DEBUG, { location, mockedUploadResult }); + const result = (async () => { + log("mockedUpload.promise mock called", LogLevel.DEBUG, mockedUploadResult); + if (duration) { + // We need a random duration so we don't get race conditions on the unlink + await util.sleep(.1 + (Math.random() * duration)); + } + return mockedUploadResult; + })(); + const mockedS3Instance: AwsStub = mockS3(); + mockedS3Instance.on(PutObjectCommand).resolves(result); + mockedS3Instance.on(CreateMultipartUploadCommand).resolves({ UploadId: "1" }); + mockedS3Instance.on(UploadPartCommand).resolves({ ETag }); + mockedS3Instance.on(CompleteMultipartUploadCommand).resolves(result); + + return location; +} + +export function mockGetObject ( + body: string | number | Buffer = "{\"message\":\"Test file to upload.\"}", + contentType: string = "application/json", + lastModified: Date = new Date() +) { + const mockedS3Instance: AwsStub = mockS3(); + if (typeof body === "number") { + log("mockGetObject size: " + body, LogLevel.DEBUG, { body, MAX_STRING_LENGTH }); + const size = body; + if (size < MAX_STRING_LENGTH) { + body = Buffer.from("x".repeat(size)); + } else { + const remainder = size - MAX_STRING_LENGTH; + log("mockGetObject remainder: " + remainder, LogLevel.DEBUG, { remainder, size, MAX_STRING_LENGTH }); + body = Buffer.concat([ + Buffer.from("x".repeat(MAX_STRING_LENGTH)), + Buffer.from("x".repeat(remainder)) + ]); + } + log("mockGetObject created size: " + body.length, LogLevel.DEBUG); + } + const stream = new Readable(); + stream.push(body); + stream.push(null); + const sdkStream = sdkStreamMixin(stream); + mockedS3Instance.on(GetObjectCommand).resolves({ + Body: sdkStream, + AcceptRanges: "bytes", + LastModified: lastModified, + ContentLength: 78, + ETag: "etag", + CacheControl: "max-age=60", + ContentType: contentType, + Metadata: {}, + TagCount: 1 + }); +} + +export function mockGetObjectError (statusCode: number = 304, code: string = "NotModified") { + const mockedS3Instance: AwsStub = mockS3(); + mockedS3Instance.on(GetObjectCommand).rejects({ + name: `${statusCode}`, + Code: code, + "$fault": "client" + }); +} + +export function mockCopyObject ( + lastModified: Date = new Date() +) { + const mockedS3Instance: AwsStub = mockS3(); + const mockedCopyObjectResult: Partial = { + CopyObjectResult: { + LastModified: lastModified, + ETag: "etag" + } + }; + mockedS3Instance.on(CopyObjectCommand).resolves(mockedCopyObjectResult); +} + +export function mockGetObjectTagging ( + tags: Map | undefined +) { + const mockedS3Instance: AwsStub = mockS3(); + const mockedGetObjectTaggingResult: Partial = { + TagSet: tags + ? [...tags].map(([key, value]:[string, string]): S3Tag => ({ Key: key, Value: value })) + : [] + }; + mockedS3Instance.on(GetObjectTaggingCommand).resolves(mockedGetObjectTaggingResult); +} + +// ////////////////////////////////////////////// +// /////////// SQS ////////////////////////////// +// ////////////////////////////////////////////// + +let _mockedSqsInstance: AwsStub | undefined; +export function mockSqs (): AwsStub { + log("mockSqs enter", LogLevel.DEBUG); + if (_mockedSqsInstance !== undefined) { + log("mockSqs exists", LogLevel.DEBUG); + return _mockedSqsInstance; + } + // _mockedSqsInstance = mockClient(SQSClient); + sqsConfig.sqsClient = undefined as any; + initSqs(); + _mockedSqsInstance = mockClient(sqsConfig.sqsClient); + log("mockSqs created", LogLevel.DEBUG, { mockedSqsInstance: _mockedSqsInstance, sqs: sqsConfig.sqsClient }); + // Always mock deleteMessage so we don't accidentally call it behind the scenes. Don't expose the call like the others + _mockedSqsInstance.on(DeleteMessageCommand).resolves({}); + // Always mock changeMessageVisibility + _mockedSqsInstance.on(ChangeMessageVisibilityCommand).resolves({}); + + return _mockedSqsInstance; +} + +export function resetMockSqs (): void { + if (_mockedSqsInstance !== undefined) { + _mockedSqsInstance.reset(); + sqsConfig.sqsClient = undefined as any; + _mockedSqsInstance = undefined; + } +} + +export function mockGetQueueAttributes ( + queueArn: string = "arn:aws:sqs:us-east-1:unittests:testqueue" +) { + const mockedSqsInstance: AwsStub = mockSqs(); + + const mockedGetQueueAttributeResult: Partial = { + Attributes: { + QueueArn: queueArn, + ApproximateNumberOfMessages: "0", + ApproximateNumberOfMessagesNotVisible: "0", + ApproximateNumberOfMessagesDelayed: "0", + CreatedTimestamp: "1570468375", + LastModifiedTimestamp: "1600956723", + VisibilityTimeout: "60", + MaximumMessageSize: "262144", + MessageRetentionPeriod: "900", + DelaySeconds: "0", + ReceiveMessageWaitTimeSeconds: "20" + } + }; + mockedSqsInstance.on(GetQueueAttributesCommand).resolves(mockedGetQueueAttributeResult); + log("mockGetQueueAttributes", LogLevel.DEBUG, { mockedGetQueueAttributes: mockedGetQueueAttributeResult, sqs: sqsConfig.sqsClient }); +} + +export function mockSendMessage () { + const mockedSendMessageResult: Partial = { + MD5OfMessageBody: "testmd5", + MD5OfMessageAttributes: "testmd5", + MessageId: "unit-test-message-id" + }; + const mockedSqsInstance: AwsStub = mockSqs(); + mockedSqsInstance.on(SendMessageCommand).resolves(mockedSendMessageResult); + log("mockSendMessage", LogLevel.DEBUG, { mockedSendMessage: mockedSendMessageResult, sqs: sqsConfig.sqsClient }); +} + +export function mockReceiveMessage ( + testId: string = "UnitTest" + Date.now(), + body: string = "Sending Message to the unittests Queue", + testMessage: string = "" +) { + const message: SQSMessage = { + MessageId: "unit-test-message-id", + ReceiptHandle: "unit-test-receipt-handle", + Body: body, + MessageAttributes: { + TestId: { + StringValue: testId, + DataType: "String" + }, + TestMessage: { + BinaryValue: Buffer.from(testMessage), + DataType: "Binary" + }, + UnitTestMessage: { + StringValue: "true", + DataType: "String" + } + } + }; + mockReceiveMessages([message]); +} + +export function mockReceiveMessageAttributes ( + messageBodyAttributeMap: Record, + body: string = "Sending Message to the unittests Queue" +) { + const message: SQSMessage = { + MessageId: "unit-test-message-id", + ReceiptHandle: "unit-test-receipt-handle", + Body: body, + MessageAttributes: messageBodyAttributeMap + }; + mockReceiveMessages([message]); +} + +export function mockReceiveMessages (messages?: SQSMessage[]) { + const mockedSqsInstance: AwsStub = mockSqs(); + + const mockedReceiveMessageResult: Partial = { + Messages: messages + }; + mockedSqsInstance.on(ReceiveMessageCommand).resolves(mockedReceiveMessageResult); + + log("mockReceiveMessages", LogLevel.DEBUG, { mockedReceiveMessage: mockedReceiveMessageResult, sqs: sqsConfig.sqsClient }); +} diff --git a/common/test/ppaascommessage.spec.ts b/common/test/ppaascommessage.spec.ts new file mode 100644 index 00000000..9b8b8deb --- /dev/null +++ b/common/test/ppaascommessage.spec.ts @@ -0,0 +1,188 @@ +import { + CommunicationsMessage, + LogLevel, + MessageType, + PpaasCommunicationsMessage, + log +} from "../src/index"; +import { + mockReceiveMessageAttributes, + mockReceiveMessages, + mockSendMessage, + mockSqs, + resetMockSqs +} from "./mock"; +import { MessageAttributeValue } from "@aws-sdk/client-sqs"; +import { QUEUE_URL_COMMUNICATION} from "../src/util/sqs"; +import { expect } from "chai"; + +class PPaasUnitCommunicationsMessage extends PpaasCommunicationsMessage { + public constructor ({ testId, + messageType, + messageData }: Partial) { + super({ testId, messageType, messageData }); + this.unittestMessage = true; + } + + public setReceiptHandle (receiptHandle: string | undefined) { + this.receiptHandle = receiptHandle; + } +} + +describe("PpaasCommunicationsMessage", () => { + const testId: string = "UnitTest" + Date.now(); + const messageType: MessageType = MessageType.TestStatus; + let ppaasUnitCommunicationsMessage: PPaasUnitCommunicationsMessage; + let fullCommunicationsMessage: Required; + + before(() => { + mockSqs(); + log("QUEUE_URL_COMMUNICATION=" + QUEUE_URL_COMMUNICATION, LogLevel.DEBUG); + ppaasUnitCommunicationsMessage = new PPaasUnitCommunicationsMessage({ testId, messageType, messageData: undefined }); + fullCommunicationsMessage = { + testId, + messageType, + messageData: { + testKey: "testValue", + secondKey: "secondValue" + } + }; + }); + + after(() => { + resetMockSqs(); + }); + + it("getCommunicationsMessage should have all properties of a CommunicationsMessage", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasCommunicationsMessage(fullCommunicationsMessage); + const actualTestMessage = fullPpaasTestMessage.getCommunicationsMessage(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullCommunicationsMessage).length); + for (const key in actualTestMessage) { + expect(JSON.stringify(actualTestMessage[key as keyof CommunicationsMessage]), key).to.equal(JSON.stringify(fullCommunicationsMessage[key as keyof CommunicationsMessage])); + } + done(); + }); + + it("sanitizedCopy should not have messageData", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PPaasUnitCommunicationsMessage(fullCommunicationsMessage); + const expectedReceiptHandle = "testhandle"; + fullPpaasTestMessage.setReceiptHandle(expectedReceiptHandle); + const actualTestMessage = fullPpaasTestMessage.sanitizedCopy(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullCommunicationsMessage).length); + for (const key in actualTestMessage) { + if (key === "messageData") { + expect(actualTestMessage.messageData, key).to.equal(undefined); + } else if (key === "receiptHandle") { + expect(actualTestMessage.receiptHandle, key).to.equal(expectedReceiptHandle); + } else { + expect(JSON.stringify(actualTestMessage[key as keyof CommunicationsMessage]), key).to.equal(JSON.stringify(fullCommunicationsMessage[key as keyof CommunicationsMessage])); + } + } + done(); + }); + + describe("Send To Communiations Retrieval SQS Queue", () => { + it("PPaasUnitCommunicationsMessage.send() controller Should succeed", (done: Mocha.Done) => { + mockSendMessage(); + ppaasUnitCommunicationsMessage.send().then((messageId: string | undefined) => { + expect(messageId).to.not.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Read From Communiations Retrieval SQS Queue", () => { + it("PPaasUnitCommunicationsMessage.getMessage() should always succeed even if empty", (done: Mocha.Done) => { + mockReceiveMessages(undefined); + PpaasCommunicationsMessage.getMessage().then((result: PpaasCommunicationsMessage | undefined) => { + log("getAnyMessageForController result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("PPaasUnitCommunicationsMessage.getMessage() should receive new MessageType", (done: Mocha.Done) => { + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: testId + }, + MessageType: { + DataType: "String", + StringValue: MessageType[messageType] + } + }; + mockReceiveMessageAttributes(messageAttributes); + PpaasCommunicationsMessage.getMessage().then((result: PpaasCommunicationsMessage | undefined) => { + log("getAnyMessageForController result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.not.equal(undefined); + expect(result?.testId, "testId").to.equal(testId); + expect(result?.messageType, "messageType").to.equal(messageType); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("PPaasUnitCommunicationsMessage.getMessage() should receive String Data", (done: Mocha.Done) => { + const messageData = "test message"; + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: testId + }, + MessageType: { + DataType: "String", + StringValue: MessageType[messageType] + }, + MessageData: { + DataType: "String", + StringValue: messageData + } + }; + mockReceiveMessageAttributes(messageAttributes); + PpaasCommunicationsMessage.getMessage().then((result: PpaasCommunicationsMessage | undefined) => { + log("getAnyMessageForController result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.not.equal(undefined); + expect(result?.testId, "testId").to.equal(testId); + expect(result?.messageType, "messageType").to.equal(messageType); + expect(result?.messageData, "messageType").to.equal(messageData); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("PPaasUnitCommunicationsMessage.getMessage() should receive Binary Data", (done: Mocha.Done) => { + const messageData = { test: true, text: "test" }; + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: testId + }, + MessageType: { + DataType: "String", + StringValue: MessageType[messageType] + }, + MessageData: { + DataType: "Binary", + BinaryValue: Buffer.from(JSON.stringify(messageData)) + } + }; + mockReceiveMessageAttributes(messageAttributes); + PpaasCommunicationsMessage.getMessage().then((result: PpaasCommunicationsMessage | undefined) => { + log("getAnyMessageForController result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.not.equal(undefined); + expect(result?.testId, "testId").to.equal(testId); + expect(JSON.stringify(result?.messageType), "messageType").to.equal(JSON.stringify(messageType)); + done(); + }).catch((error) => { + done(error); + }); + }); + }); +}); diff --git a/common/test/ppaass3message.spec.ts b/common/test/ppaass3message.spec.ts new file mode 100644 index 00000000..9934a9bd --- /dev/null +++ b/common/test/ppaass3message.spec.ts @@ -0,0 +1,136 @@ +import { CommunicationsMessage, LogLevel, MessageType, PpaasS3Message, PpaasTestId, log } from "../src/index"; +import { + mockGetObject, + mockListObject, + mockListObjects, + mockS3, + mockUploadObject, + resetMockS3 +} from "./mock"; +import { createS3Filename } from "../src/ppaass3message"; +import { expect } from "chai"; + +describe("PpaasS3Message", () => { + let ppaasTestId: PpaasTestId; + const messageType: MessageType = MessageType.UpdateYaml; + let ppaasS3Message: PpaasS3Message | undefined; + let testFilename: string; + let testFolder: string; + let fullCommunicationsMessage: Required; + + before(() => { + mockS3(); + ppaasTestId = PpaasTestId.makeTestId("UnitTest"); + testFilename = createS3Filename(ppaasTestId); + testFolder = ppaasTestId.s3Folder; + fullCommunicationsMessage = { + testId: ppaasTestId.testId, + messageType, + messageData: { + testKey: "testValue", + secondKey: "secondValue" + } + }; + }); + + after(async () => { + // Call delete before the reset mock so we can "test" it + if (ppaasS3Message) { + await ppaasS3Message.deleteMessageFromS3(); + } + resetMockS3(); + }); + + it("getCommunicationsMessage should have all properties of a CommunicationsMessage", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasS3Message(fullCommunicationsMessage); + const actualTestMessage = fullPpaasTestMessage.getCommunicationsMessage(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullCommunicationsMessage).length); + for (const key in actualTestMessage) { + expect(JSON.stringify(actualTestMessage[key as keyof CommunicationsMessage]), key).to.equal(JSON.stringify(fullCommunicationsMessage[key as keyof CommunicationsMessage])); + } + done(); + }); + + it("sanitizedCopy should not have messageData", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasS3Message(fullCommunicationsMessage); + const actualTestMessage = fullPpaasTestMessage.sanitizedCopy(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullCommunicationsMessage).length); + for (const key in actualTestMessage) { + if (key === "messageData") { + expect(actualTestMessage.messageData, key).to.equal(undefined); + } else if (key === "inS3") { + expect(actualTestMessage.inS3, "inS3").to.equal(false); + } else { + expect(JSON.stringify(actualTestMessage[key as keyof CommunicationsMessage]), key).to.equal(JSON.stringify(fullCommunicationsMessage[key as keyof CommunicationsMessage])); + } + } + done(); + }); + + describe("Send To Communiations S3 Queue", () => { + it("PpaasS3Message.send() should succeed", (done: Mocha.Done) => { + mockUploadObject({ filename: testFilename, folder: testFolder }); + log("creating ppaasUnitS3Message", LogLevel.DEBUG); + try { + ppaasS3Message = new PpaasS3Message({ testId: ppaasTestId, messageType, messageData: undefined}); + log("ppaasUnitS3Message", LogLevel.DEBUG, ppaasS3Message.sanitizedCopy()); + ppaasS3Message.send().then((url: string | undefined) => { + log("PpaasS3Message.send() result: " + url, LogLevel.DEBUG); + expect(url).to.not.equal(undefined); + done(); + }).catch((error) => { + log("PpaasS3Message.send() error", LogLevel.ERROR, error); + done(error); + }); + } catch (error) { + log("Send To Communiations S3 Queue Error", LogLevel.ERROR, error); + done(error); + } + }); + }); + + describe("Read From Communiations S3 Queue", () => { + before (() => { + if (ppaasS3Message === undefined) { + // mockUploadObject(testFilename, testFolder); + ppaasS3Message = new PpaasS3Message({ testId: ppaasTestId, messageType, messageData: undefined }); + log("ppaasUnitS3Message", LogLevel.DEBUG, ppaasS3Message.sanitizedCopy()); + // await ppaasS3Message.send(); + } + }); + + it("PpaasS3Message.getMessage should always succeed even if empty", (done: Mocha.Done) => { + mockListObjects(undefined); + PpaasS3Message.getMessage(ppaasTestId).then((result: PpaasS3Message | undefined) => { + log("PpaasS3Message.getMessage result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.equal(undefined); + done(); + }).catch((error) => { + log("PpaasS3Message.getMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasS3Message.getMessage should always succeed and return on messageType", (done: Mocha.Done) => { + expect(ppaasS3Message, "ppaasS3Message").to.not.equal(undefined); + mockListObject(testFilename, testFolder); + const communicationsMessage: CommunicationsMessage = { + testId: ppaasTestId.testId, + messageType: ppaasS3Message!.messageType, + messageData: undefined + }; + mockGetObject(JSON.stringify(communicationsMessage), "application/json"); + PpaasS3Message.getMessage(ppaasTestId).then((result: PpaasS3Message | undefined) => { + log("PpaasS3Message.getMessage result", LogLevel.DEBUG, result && result.sanitizedCopy()); + expect(result).to.not.equal(undefined); + expect(result?.testId, "testId").to.equal(communicationsMessage.testId); + expect(result?.messageType, "messageType").to.equal(communicationsMessage.messageType); + expect(result?.messageData, "messageData").to.equal(communicationsMessage.messageData); + done(); + }).catch((error) => { + log("PpaasS3Message.getMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + }); +}); diff --git a/common/test/ppaastestid.spec.ts b/common/test/ppaastestid.spec.ts new file mode 100644 index 00000000..d0803e8d --- /dev/null +++ b/common/test/ppaastestid.spec.ts @@ -0,0 +1,62 @@ +import { PpaasTestId } from "../src/index"; +import { expect } from "chai"; + +describe("TestId", () => { + it("Default should convert there and back", (done: Mocha.Done) => { + const origTestId = PpaasTestId.makeTestId("test.yaml"); + const testId: string | undefined = origTestId.testId; + expect(testId).to.not.equal(undefined); + const newTestId = PpaasTestId.getFromTestId(origTestId.testId); + expect(origTestId.dateString).to.equal(newTestId.dateString); + expect(testId).to.equal(newTestId.testId); + expect(origTestId.date.getTime()).to.equal(newTestId.date.getTime()); + done(); + }); + + it("Specified should convert there and back", (done: Mocha.Done) => { + const origDate = new Date(); + const dateString = PpaasTestId.getDateString(origDate); + const origTestId = PpaasTestId.makeTestId("test.yaml", { dateString }); + const testId: string | undefined = origTestId.testId; + expect(testId).to.not.equal(undefined); + const newTestId = PpaasTestId.getFromTestId(origTestId.testId); + expect(origTestId.dateString).to.equal(newTestId.dateString); + expect(testId).to.equal(newTestId.testId); + expect(origDate.getTime()).to.equal(newTestId.date.getTime()); + done(); + }); + + it("Profile should be appended", (done: Mocha.Done) => { + const origTestId = PpaasTestId.makeTestId("test.yaml", { profile: "dev" }); + const testId: string | undefined = origTestId.testId; + expect(testId).to.not.equal(undefined); + expect(testId).to.include("testdev"); + done(); + }); + + it("PewPew should fail", (done: Mocha.Done) => { + try { + PpaasTestId.makeTestId("pewpew.yaml"); + done(new Error("PewPew should not be a valid testId")); + } catch (error) { + expect(`${error}`).to.include("Yaml File cannot be named PewPew"); + done(); + } + }); + + it("Everything should be lowercaseed", (done: Mocha.Done) => { + const origTestId = PpaasTestId.makeTestId("TEST.yaml", { profile: "DEV" }); + const testId: string | undefined = origTestId.testId; + expect(testId).to.not.equal(undefined); + expect(testId).to.include("testdev"); + done(); + }); + + it("Non-characters and numbers should be removed", (done: Mocha.Done) => { + const origTestId = PpaasTestId.makeTestId("Test5-3_0.6.yaml", { profile: "DEV-Aux" }); + const testId: string | undefined = origTestId.testId; + expect(testId).to.not.equal(undefined); + expect(testId).to.include("test5306devaux"); + done(); + }); +}); diff --git a/common/test/ppaastestmessage.spec.ts b/common/test/ppaastestmessage.spec.ts new file mode 100644 index 00000000..9d1ac640 --- /dev/null +++ b/common/test/ppaastestmessage.spec.ts @@ -0,0 +1,146 @@ +import { AgentQueueDescription, LogLevel, PpaasTestMessage, TestMessage, log } from "../src/index"; +import { UNIT_TEST_FILENAME, UNIT_TEST_KEY_PREFIX } from "../test/s3.spec"; +import { + mockReceiveMessageAttributes, + mockSendMessage, + mockSqs, + resetMockSqs +} from "../test/mock"; +import { MessageAttributeValue } from "@aws-sdk/client-sqs"; +import { QUEUE_URL_TEST } from "../src/util/sqs"; +import { expect } from "chai"; + +class PPaasUnitTestMessage extends PpaasTestMessage { + public constructor (testMessage: TestMessage) { + super(testMessage); + this.unittestMessage = true; + } +} + +describe("PpaasTestMessage", () => { + const testId: string = "UnitTest" + Date.now(); + const s3Folder: string = UNIT_TEST_KEY_PREFIX; + const yamlFile: string = UNIT_TEST_FILENAME; + const testRunTimeMn: number = 1; + let ppaasUnitTestMessage: PPaasUnitTestMessage; + let ppaasTestMessage: PpaasTestMessage | undefined; + const fullTestMessage: Required = { + testId, + s3Folder, + yamlFile, + testRunTimeMn, + envVariables: { + var1: "var1value", + var2: "var2value", + var3: "var3value" + }, + restartOnFailure: true, + bucketSizeMs: 60000, + version: "latest", + additionalFiles: ["file1", "file2"], + userId: "bruno@madrigal.family", + bypassParser: false + }; + + before(() => { + mockSqs(); + log("QUEUE_URL_TEST=" + [...QUEUE_URL_TEST], LogLevel.DEBUG); + ppaasUnitTestMessage = new PPaasUnitTestMessage({ testId, s3Folder, yamlFile, testRunTimeMn, envVariables: {}, restartOnFailure: true, bucketSizeMs: 60000, version: "latest" }); + }); + + after(() => { + resetMockSqs(); + }); + + it("getAvailableQueueNames should always return at least one entry", (done: Mocha.Done) => { + const queueNames: string[] = PpaasTestMessage.getAvailableQueueNames(); + log(`PpaasTestMessage.getAvailableQueueNames queueNames = ${queueNames}`, queueNames.length > 0 ? LogLevel.DEBUG : LogLevel.ERROR, queueNames); + expect(queueNames.length, "queueNames = " + JSON.stringify(queueNames)).to.be.greaterThan(0); + done(); + }); + + it("getAvailableQueueMap should always return at least one entry", (done: Mocha.Done) => { + const queueMap: AgentQueueDescription = PpaasTestMessage.getAvailableQueueMap(); + log("PpaasTestMessage.getAvailableQueueNames queueNames", Object.keys(queueMap).length > 0 ? LogLevel.DEBUG : LogLevel.ERROR, queueMap); + expect(Object.keys(queueMap).length, "queueMap = " + JSON.stringify(queueMap)).to.be.greaterThan(0); + done(); + }); + + it("Send Test Should succeed", (done: Mocha.Done) => { + mockSendMessage(); + ppaasUnitTestMessage.send(QUEUE_URL_TEST.keys().next().value).then(() => { + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + + it("getNewTestToRun should always succeed even if empty", (done: Mocha.Done) => { + const messageAttributes: Record = { + TestId: { + DataType: "String", + StringValue: testId + }, + TestMessage: { + DataType: "Binary", + BinaryValue: Buffer.from(JSON.stringify(ppaasUnitTestMessage.getTestMessage())) + } + }; + mockReceiveMessageAttributes(messageAttributes); + PpaasTestMessage.getNewTestToRun().then((result: PpaasTestMessage | undefined) => { + log(`receiveMessage result = ${result && result.toString()}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + expect(result?.receiptHandle, "result.receiptHandle").to.not.equal(undefined); + expect(result?.testId, "result.testId").to.not.equal(undefined); + ppaasTestMessage = result; + done(); + }).catch((error) => { + done(error); + }); + }); + + it("extendMessageVisibility should succeed", (done: Mocha.Done) => { + if (ppaasTestMessage === undefined) { + ppaasTestMessage = new PpaasTestMessage({ testId, s3Folder, yamlFile, testRunTimeMn, envVariables: {}, restartOnFailure: true, bucketSizeMs: 60000, version: "latest" }); + } + if (ppaasTestMessage.receiptHandle === undefined) { + ppaasTestMessage.receiptHandle = "unit-test-receipt-handle"; + } + ppaasTestMessage.extendMessageLockout().then(() => done()).catch((error) => done(error)); + }); + + it("deleteMessageFromQueue should succeed", (done: Mocha.Done) => { + if (ppaasTestMessage === undefined) { + ppaasTestMessage = new PpaasTestMessage({ testId, s3Folder, yamlFile, testRunTimeMn, envVariables: {}, restartOnFailure: true, bucketSizeMs: 60000, version: "latest" }); + } + if (ppaasTestMessage.receiptHandle === undefined) { + ppaasTestMessage.receiptHandle = "unit-test-receipt-handle"; + } + ppaasTestMessage.deleteMessageFromQueue().then(() => done()).catch((error) => done(error)); + }); + + it("getTestMessage should have all properties of a TestMessage", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasTestMessage(fullTestMessage); + const actualTestMessage = fullPpaasTestMessage.getTestMessage(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullTestMessage).length); + for (const key in actualTestMessage) { + expect(JSON.stringify(actualTestMessage[key as keyof TestMessage]), key).to.equal(JSON.stringify(fullTestMessage[key as keyof TestMessage])); + } + done(); + }); + + it("sanitizedCopy should only have the keys of envVariables", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasTestMessage(fullTestMessage); + const actualTestMessage = fullPpaasTestMessage.sanitizedCopy(); + expect(Object.keys(actualTestMessage).length, Object.keys(actualTestMessage).toString() + " length").to.equal(Object.keys(fullTestMessage).length); + for (const key in actualTestMessage) { + if (key === "envVariables") { + expect(JSON.stringify(actualTestMessage.envVariables), key).to.equal(JSON.stringify(Object.keys(fullTestMessage.envVariables))); + } else { + expect(JSON.stringify(actualTestMessage[key as keyof TestMessage]), key).to.equal(JSON.stringify(fullTestMessage[key as keyof TestMessage])); + } + } + done(); + }); +}); diff --git a/common/test/ppaasteststatus.spec.ts b/common/test/ppaasteststatus.spec.ts new file mode 100644 index 00000000..571f8d19 --- /dev/null +++ b/common/test/ppaasteststatus.spec.ts @@ -0,0 +1,272 @@ +import { + LogLevel, + PpaasTestId, + PpaasTestStatus, + TestStatus, + TestStatusMessage, + log +} from "../src/index"; +import { + mockGetObject, + mockGetObjectError, + mockListObject, + mockListObjects, + mockS3, + mockUploadObject, + resetMockS3 +} from "./mock"; +import { createS3Filename } from "../src/ppaasteststatus"; +import { expect } from "chai"; + +class PpaasUnitTestStatus extends PpaasTestStatus { + public setUrl (value: string | undefined) { + this.url = value; + } +} + +describe("PpaasTestStatus", () => { + let ppaasTestId: PpaasTestId; + // Required<> so that any new properties will fail until we add them to our test. + let testStatus: Required; + let ppaasTestStatus: PpaasTestStatus | undefined; + let testFilename: string; + let testFolder: string; + + before(() => { + mockS3(); + ppaasTestId = PpaasTestId.makeTestId("UnitTest"); + testStatus = { + instanceId: "i-testinstance", + hostname: "localhost", + ipAddress: "127.0.0.1", + startTime: Date.now() - 60000, + endTime: Date.now(), + resultsFilename: [ppaasTestId.testId + ".json"], + status: TestStatus.Running, + errors: ["Test Error"], + version: "latest", + queueName: "unittest", + userId: "unittestuser" + }; + testFilename = createS3Filename(ppaasTestId); + testFolder = ppaasTestId.s3Folder; + }); + + after(() => { + resetMockS3(); + }); + + it("getTestStatusMessage should have all properties of a TestStatusMessage", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasTestStatus(ppaasTestId, testStatus); + const actualTestMessage = fullPpaasTestMessage.getTestStatusMessage(); + expect(Object.keys(actualTestMessage).length, `Actual Keys: ${Object.keys(actualTestMessage).toString()}\nExpected Keys: ${Object.keys(testStatus).toString()}\nMessage keys length`).to.equal(Object.keys(testStatus).length); + for (const key in actualTestMessage) { + expect(JSON.stringify(actualTestMessage[key as keyof TestStatusMessage]), key).to.equal(JSON.stringify(testStatus[key as keyof TestStatusMessage])); + } + done(); + }); + + it("sanitizedCopy should only be a TestStatusMessage + testId", (done: Mocha.Done) => { + const fullPpaasTestMessage = new PpaasUnitTestStatus(ppaasTestId, testStatus); + const exepectedUrl = "bogus"; + fullPpaasTestMessage.setUrl(exepectedUrl); + const actualTestMessage = fullPpaasTestMessage.sanitizedCopy(); + // should have all the TestStatus + lastModifiedRemote && testId && url + expect(Object.keys(actualTestMessage).length, `Actual Keys: ${Object.keys(actualTestMessage).toString()}\nExpected Keys: ${Object.keys(testStatus).toString()}\nMessage keys length`).to.equal(Object.keys(testStatus).length + 3); + for (const key in actualTestMessage) { + switch(key) { + case "lastModifiedRemote": + expect(JSON.stringify(actualTestMessage.lastModifiedRemote), key).to.equal(JSON.stringify(new Date(0))); + break; + case "testId": + expect(actualTestMessage.testId, key).to.equal(ppaasTestId.testId); + break; + case "url": + expect(actualTestMessage.url, key).to.equal(exepectedUrl); + break; + default: + expect(JSON.stringify(actualTestMessage[key as keyof TestStatusMessage]), key).to.equal(JSON.stringify(testStatus[key as keyof TestStatusMessage])); + break; + } + } + done(); + }); + + describe("Send Status to S3", () => { + it("PpaasTestStatus.writeStatus() should succeed", (done: Mocha.Done) => { + mockUploadObject({ filename: testFilename, folder: testFolder }); + log("creating ppaasTestStatus", LogLevel.DEBUG); + try { + ppaasTestStatus = new PpaasTestStatus(ppaasTestId, testStatus); + log("ppaasTestStatus", LogLevel.DEBUG, ppaasTestStatus.sanitizedCopy()); + ppaasTestStatus.writeStatus().then((url: string | undefined) => { + log("PpaasTestStatus.send() result: " + url, LogLevel.DEBUG); + expect(url).to.not.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.send() error", LogLevel.ERROR, error); + done(error); + }); + } catch (error) { + log("Send To Communiations S3 Queue Error", LogLevel.ERROR, error); + done(error); + } + }); + }); + + describe("Read Status from S3", () => { + let lastModifiedRemote: Date | undefined; + let contents: string; + + before (async () => { + expect(ppaasTestId).to.not.equal(undefined); + if (ppaasTestStatus === undefined) { + ppaasTestStatus = new PpaasTestStatus(ppaasTestId, testStatus); + mockUploadObject({ filename: testFilename, folder: testFolder }); + await ppaasTestStatus.writeStatus(); + } + contents = JSON.stringify(ppaasTestStatus.getTestStatusMessage()); + log("contents", LogLevel.DEBUG, { contents, testStatus }); + }); + + beforeEach(() => { + lastModifiedRemote = ppaasTestStatus?.getLastModifiedRemote(); + }); + + it("PpaasTestStatus.getStatus not existent", (done: Mocha.Done) => { + mockListObjects([]); + PpaasTestStatus.getStatus(PpaasTestId.makeTestId("noexist")).then((result: PpaasTestStatus | undefined) => { + log("PpaasTestStatus.getStatus result", LogLevel.DEBUG, result); + expect(result).to.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.getStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.getStatus exists", (done: Mocha.Done) => { + mockListObject(testFilename, testFolder); + mockGetObject(contents, "application/json", lastModifiedRemote); + PpaasTestStatus.getStatus(ppaasTestId).then((result: PpaasTestStatus | undefined) => { + log("PpaasTestStatus.getStatus result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + if (result) { + lastModifiedRemote = result.getLastModifiedRemote(); + } + done(); + }).catch((error) => { + log("PpaasTestStatus.getStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.getStatus not existent", (done: Mocha.Done) => { + mockListObjects([]); + PpaasTestStatus.getAllStatus("noexist").then((result: Promise[] | undefined) => { + log("PpaasTestStatus.getAllStatus result", LogLevel.DEBUG, result); + expect(result).to.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.getAllStatus exists", (done: Mocha.Done) => { + mockListObject(testFilename, testFolder, lastModifiedRemote); + mockGetObject(contents, "application/json", lastModifiedRemote); + PpaasTestStatus.getAllStatus("unittest") + .then((result: Promise[] | undefined) => { + log("PpaasTestStatus.getAllStatus result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.length).to.be.greaterThan(0); + Promise.all(result!).then((statuses: (PpaasTestStatus | undefined)[]) => { + let foundStatus: PpaasTestStatus | undefined; + for (const status of statuses) { + expect(status).to.not.equal(undefined); + if (status!.getTestId() === ppaasTestId.testId) { + foundStatus = status; + break; + } + } + expect(foundStatus, "found ppaasTestStatus").to.not.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.getAllStatus ignored", (done: Mocha.Done) => { + mockListObject(testFilename, testFolder, lastModifiedRemote); + PpaasTestStatus.getAllStatus("unittest", 1000, [ppaasTestId.testId]) + .then((result: Promise[] | undefined) => { + log("PpaasTestStatus.getAllStatus result", LogLevel.DEBUG, result); + expect(result).to.equal(undefined); + done(); + }).catch((error) => { + log("PpaasTestStatus.getAllStatus error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PpaasTestStatus.readStatus should not change lastModified", (done: Mocha.Done) => { + if (ppaasTestStatus && lastModifiedRemote) { + mockListObject(testFilename, testFolder, lastModifiedRemote); + mockGetObjectError(304); + ppaasTestStatus.readStatus().then((result: Date) => { + log("PpaasTestStatus.readStatus result", LogLevel.DEBUG, result); + expect(result.getTime()).to.equal(lastModifiedRemote!.getTime()); + done(); + }).catch((error) => { + log("PpaasTestStatus.readStatus error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(new Error("ppaasTestStatus was not initialized")); + } + }); + + it("PpaasTestStatus.readStatus should update values", (done: Mocha.Done) => { + if (ppaasTestStatus && lastModifiedRemote) { + const emptyStatus: TestStatusMessage = { + startTime: Date.now() - 60000, + endTime: Date.now(), + resultsFilename: ["bad"], + status: TestStatus.Created + }; + mockListObject(testFilename, testFolder, lastModifiedRemote); + mockGetObject(contents, "application/json", lastModifiedRemote); + const emptyTestStatus: PpaasTestStatus = new PpaasTestStatus(ppaasTestId, emptyStatus); + emptyTestStatus.readStatus().then((result: Date) => { + log("PpaasTestStatus.readStatus result", LogLevel.DEBUG, result); + expect(result.getTime(), "getTime()").to.equal(lastModifiedRemote!.getTime()); + expect(emptyTestStatus.startTime, "startTime").to.equal(testStatus.startTime); + expect(emptyTestStatus.endTime, "endTime").to.equal(testStatus.endTime); + expect(emptyTestStatus.resultsFilename.length, "resultsFilename.length").to.equal(1); + expect(emptyTestStatus.resultsFilename[0], "resultsFilename[0]").to.equal(testStatus.resultsFilename[0]); + expect(emptyTestStatus.getLastModifiedRemote().getTime(), "getLastModifiedRemote()").to.equal(lastModifiedRemote!.getTime()); + expect(emptyTestStatus.errors, "errors").to.not.equal(undefined); + expect(emptyTestStatus.errors!.length, "errors.length").to.equal(1); + expect(emptyTestStatus.errors![0], "errors[0]").to.equal(testStatus.errors![0]); + expect(emptyTestStatus.instanceId, "instanceId").to.equal(testStatus.instanceId); + expect(emptyTestStatus.hostname, "hostname").to.equal(testStatus.hostname); + expect(emptyTestStatus.ipAddress, "ipAddress").to.equal(testStatus.ipAddress); + expect(emptyTestStatus.version, "version").to.equal(testStatus.version); + expect(emptyTestStatus.queueName, "queueName").to.equal(testStatus.queueName); + done(); + }).catch((error) => { + log("PpaasTestStatus.readStatus error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(new Error("ppaasTestStatus was not initialized")); + } + }); + }); +}); diff --git a/common/test/s3.spec.ts b/common/test/s3.spec.ts new file mode 100644 index 00000000..3b1c6b12 --- /dev/null +++ b/common/test/s3.spec.ts @@ -0,0 +1,675 @@ +import * as path from "path"; +import { + CompleteMultipartUploadCommandOutput, + CopyObjectCommandOutput, + GetObjectCommandOutput, + GetObjectTaggingCommandOutput, + ListObjectsV2CommandOutput, + PutObjectTaggingCommandOutput, + _Object as S3Object +} from "@aws-sdk/client-s3"; +import { LogLevel, S3File, log } from "../src/index"; +import { Stats, createReadStream } from "fs"; +import { + UNIT_TEST_KEYSPACE_PREFIX, + mockCopyObject, + mockGetObject, + mockGetObjectError, + mockGetObjectTagging, + mockListObjects, + mockS3, + mockUploadObject, + resetMockS3 +} from "./mock"; +import { + copyFile, + copyObject, + defaultTestFileTags, + getFile, + getFileContents, + getObject, + getObjectTagging, + getTags, + listFiles, + listObjects, + putObjectTagging, + putTags, + setAccessCallback, + uploadFile, + uploadFileContents, + uploadObject +} from "../src/util/s3"; +import { constants as bufferConstants } from "node:buffer"; +import { expect } from "chai"; +import fs from "fs/promises"; +import { promisify } from "util"; +import { gunzip as zlibGunzip } from "zlib"; + +const gunzip = promisify(zlibGunzip); +const { MAX_STRING_LENGTH } = bufferConstants; + +export const UNIT_TEST_KEY_PREFIX: string = process.env.UNIT_TEST_KEY_PREFIX || "unittest"; +export const UNIT_TEST_FILENAME: string = process.env.UNIT_TEST_FILENAME || "s3test.txt"; +export const UNIT_TEST_FILEPATH: string = process.env.UNIT_TEST_FILEPATH || ("test/" + UNIT_TEST_FILENAME); +export const UNIT_TEST_LOCAL_FILE_LOCATION: string = process.env.UNIT_TEST_LOCAL_FILE_LOCATION || process.env.TEMP || "/tmp"; +export const MAX_POLL_WAIT: number = parseInt(process.env.MAX_POLL_WAIT || "0", 10) || 500; + +const UNIT_TEST_KEY: string = `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`; + +const s3TestObject: S3Object = { + Key: UNIT_TEST_KEY, + LastModified: new Date(), + Size: 1, + StorageClass: "STANDARD" +}; + +describe("S3Util", () => { + let healthCheckDate: Date | undefined; + + before ( () => { + mockS3(); + // Set the access callback to test that healthchecks will be updated + setAccessCallback((date: Date) => healthCheckDate = date); + }); + + after(() => { + // Reset the mock + resetMockS3(); + }); + + beforeEach( () => { + // Set the access callback back undefined + healthCheckDate = undefined; + }); + + afterEach ( () => { + // If this is still undefined the access callback failed and was not updated with the last access date + log("afterEach healthCheckDate=" + healthCheckDate, healthCheckDate ? LogLevel.DEBUG : LogLevel.ERROR); + expect(healthCheckDate, "healthCheckDate").to.not.equal(undefined); + }); + + describe("TEST_FILE_TAGS", () => { + it("should have key test", (done: Mocha.Done) => { + try { + expect(defaultTestFileTags()).to.not.equal(undefined); + expect(defaultTestFileTags().size).to.equal(1); + expect(defaultTestFileTags().has("test")).to.equal(true); + expect(defaultTestFileTags().get("test")).to.equal("true"); + // This won't be set on failures, set it so the afterEach doesn't throw + healthCheckDate = new Date(); + done(); + } catch (error) { + done(error); + } + }); + + it("should should not be modifiable", (done: Mocha.Done) => { + try { + defaultTestFileTags().set("unittest", "true"); + defaultTestFileTags().delete("test"); + expect(defaultTestFileTags()).to.not.equal(undefined); + expect(defaultTestFileTags().size).to.equal(1); + expect(defaultTestFileTags().has("test")).to.equal(true); + expect(defaultTestFileTags().get("test")).to.equal("true"); + // This won't be set on failures, set it so the afterEach doesn't throw + healthCheckDate = new Date(); + done(); + } catch (error) { + done(error); + } + }); + }); + + describe("List Objects Empty in S3", () => { + it("List Objects should always succeed even if empty", (done: Mocha.Done) => { + mockListObjects([]); + listObjects({ prefix: "bogus", maxKeys: 1}).then((result: ListObjectsV2CommandOutput) => { + log(`listObjects("bogus", 1) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.Contents).to.not.equal(undefined); + expect(result.Contents!.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("List Files", () => { + it("List Files should always succeed even if empty", (done: Mocha.Done) => { + mockListObjects([]); + listFiles("bogus").then((result: S3Object[]) => { + log(`listFiles("bogus") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("List Files should return files", (done: Mocha.Done) => { + mockListObjects([s3TestObject]); + listFiles(UNIT_TEST_KEY_PREFIX).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.equal(1); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("List Files with extension should return files", (done: Mocha.Done) => { + mockListObjects([s3TestObject]); + listFiles({ + s3Folder: UNIT_TEST_KEY_PREFIX, + extension: UNIT_TEST_FILENAME.slice(-3) + }).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}", undefined, ${UNIT_TEST_FILENAME.slice(-3)}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(1); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("List Files with not found extension should not return files", (done: Mocha.Done) => { + mockListObjects([s3TestObject]); + listFiles({ + s3Folder: UNIT_TEST_KEY_PREFIX, + extension: "bad" + }).then((result: S3Object[]) => { + log(`listFiles("${UNIT_TEST_KEY_PREFIX}", undefined, "bad") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload Object to S3", () => { + it("Upload a test object to S3", (done: Mocha.Done) => { + const baseName: string = path.basename(UNIT_TEST_FILEPATH); + const s3File: S3File = { + body: createReadStream(UNIT_TEST_FILEPATH), + key: `${UNIT_TEST_KEY_PREFIX}/${baseName}`, + contentType: "application/json" + }; + const expectedLocation = mockUploadObject({ filename: baseName, folder: UNIT_TEST_KEY_PREFIX }); + uploadObject(s3File).then((result: CompleteMultipartUploadCommandOutput) => { + log(`uploadObject result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.Location).to.equal(expectedLocation); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("should not modify existing tags", (done: Mocha.Done) => { + const tags = new Map([["unittest", "true"]]); + const tagsBefore = tags.size; + const baseName: string = path.basename(UNIT_TEST_FILEPATH); + const s3File: S3File = { + body: createReadStream(UNIT_TEST_FILEPATH), + key: `${UNIT_TEST_KEY_PREFIX}/${baseName}`, + contentType: "application/json", + tags + }; + const expectedLocation = mockUploadObject({ filename: baseName, folder: UNIT_TEST_KEY_PREFIX }); + uploadObject(s3File).then((result: CompleteMultipartUploadCommandOutput) => { + log(`uploadObject result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.Location).to.equal(expectedLocation); + // The additional default tag should not modify the existing object + expect(tags.size).to.equal(tagsBefore); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File to S3", () => { + it("Upload a test file to S3", (done: Mocha.Done) => { + const expectedLocation = mockUploadObject({ filename: path.basename(UNIT_TEST_FILEPATH), folder: UNIT_TEST_KEY_PREFIX }); + uploadFile({ filepath: UNIT_TEST_FILEPATH, s3Folder: UNIT_TEST_KEY_PREFIX }).then((url: string) => { + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + expect(url).to.equal(expectedLocation); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File Contents to S3", () => { + it("Upload a test string to S3", (done: Mocha.Done) => { + const filename: string = path.basename(UNIT_TEST_FILEPATH); + const expectedLocation = mockUploadObject({ filename, folder: UNIT_TEST_KEY_PREFIX }); + uploadFileContents({ contents: "test", filename, s3Folder: UNIT_TEST_KEY_PREFIX }).then((url: string) => { + log(`uploadResults url = ${JSON.stringify(url)}`, LogLevel.DEBUG); + expect(url).to.equal(expectedLocation); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Get Objects in S3", () => { + let lastModified: Date; + beforeEach ( () => { + lastModified = new Date(); + }); + + it("Get Object should return files", (done: Mocha.Done) => { + mockGetObject(); + getObject(UNIT_TEST_KEY).then(async (result: GetObjectCommandOutput | undefined) => { + log(`getObject(${UNIT_TEST_KEY}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result!.Body).to.not.equal(undefined); + if (result!.ContentEncoding === "gzip" && result!.Body && typeof result!.Body == "object") { + const body: Buffer = Buffer.from(await result!.Body.transformToByteArray()); + const zresult: Buffer = await gunzip(body); + log(`result.Body = ${zresult.toString()}`, LogLevel.DEBUG); + done(); + } else { + log(`result.Body = ${await result!.Body!.transformToString()}`, LogLevel.DEBUG); + done(); + } + }).catch((error) => { + done(error); + }); + }); + + it("Get Object should return changed files", (done: Mocha.Done) => { + mockGetObject(); + const testModified: Date = new Date(lastModified.getTime() - 1000); + getObject(UNIT_TEST_KEY, testModified).then((result: GetObjectCommandOutput | undefined) => { + log(`getObject(${UNIT_TEST_KEY}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result!.Body).to.not.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Get Object should not return unchanged files", (done: Mocha.Done) => { + mockGetObjectError(304); + const testModified: Date = new Date(lastModified.getTime() + 1000); + getObject(UNIT_TEST_KEY, testModified).then((_result: GetObjectCommandOutput | undefined) => { + done(new Error("Should not succeed. We should get a Not Modified")); + }).catch((error) => { + log(`getObject(${UNIT_TEST_KEY}) error = ${error}`, LogLevel.DEBUG, error); + try { + expect(error, "error").to.not.equal(undefined); + expect(error?.name, "error?.name").to.equal("304"); + done(); + } catch (error2) { + done(error2); + } + }); + }); + }); + + describe("Get Files in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + let localFile: string | undefined; + + beforeEach (() => { + lastModified = new Date(); + }); + + afterEach (async () => { + // Delete the local file + if(localFile) { + await fs.unlink(localFile) + .catch((error) => log("Could not delete " + localFile, LogLevel.WARN, error)); + } + }); + + it("Get File should return files", (done: Mocha.Done) => { + mockGetObject(); + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${testFilename}) result = ${JSON.stringify(downloadedLastModified)}`, LogLevel.DEBUG); + expect(downloadedLastModified).to.not.equal(undefined); + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + + it("Get File should return changed files", (done: Mocha.Done) => { + mockGetObject(); + const testModified: Date = new Date(lastModified.getTime() - 1000); + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + lastModified: testModified + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${testFilename}) result = ${JSON.stringify(downloadedLastModified)}`, LogLevel.DEBUG); + expect(downloadedLastModified).to.not.equal(undefined); + localFile = path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename); + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + + it("Get File should not return unchanged files", (done: Mocha.Done) => { + mockGetObjectError(304); + const testModified: Date = new Date(lastModified.getTime() + 1000); + getFile({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + lastModified: testModified + }).then((downloadedLastModified: Date | undefined) => { + log(`getFile(${testFilename}) result = ${JSON.stringify(downloadedLastModified)}`, LogLevel.DEBUG); + expect(downloadedLastModified).to.equal(undefined); + localFile = undefined; + fs.stat(path.join(UNIT_TEST_LOCAL_FILE_LOCATION, testFilename)).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.equal(undefined); + done(); + }).catch((_error) => { + // We shouldn't have a stats object + done(); + }); + }).catch((error) => done(error)); + }); + }); + + describe("Get File Contents in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + const expectedContents: string = "This is only a test"; + + beforeEach ( () => { + lastModified = new Date(); + }); + + it("getFileContents should return contents", (done: Mocha.Done) => { + mockGetObject(expectedContents); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents maxLength should return contents", (done: Mocha.Done) => { + mockGetObject(expectedContents); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + maxLength: 5000 + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents maxLength should truncate contents", (done: Mocha.Done) => { + mockGetObject(expectedContents); + const maxLength = 5; + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + maxLength + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents.substring(0, maxLength)); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents should return changed contents", (done: Mocha.Done) => { + mockGetObject(expectedContents); + const testModified: Date = new Date(lastModified.getTime() - 1000); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + lastModified: testModified + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(expectedContents); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents should not return unchanged contents", (done: Mocha.Done) => { + mockGetObjectError(304); + const testModified: Date = new Date(lastModified.getTime() + 1000); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + lastModified: testModified + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + expect(contents).to.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + + it("getFileContents should throw on not found", (done: Mocha.Done) => { + mockGetObjectError(404); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents)}`, LogLevel.DEBUG); + done(new Error("getFileContents should have thrown")); + }).catch((error) => { + log(`${error}`, LogLevel.ERROR); + try { + try { + expect(error, "error").to.not.equal(undefined); + expect(error?.name, "error?.name").to.equal("404"); + // This won't be set on failures, set it so the afterEach doesn't throw + healthCheckDate = new Date(); + done(); + } catch (error2) { + done(error2); + } + } catch (error) { + done(error); + } + }); + }); + + it("getFileContents too large should fail", (done: Mocha.Done) => { + mockGetObject(MAX_STRING_LENGTH + 10); + const maxLength = MAX_STRING_LENGTH + 1; + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX, + maxLength + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents?.length)}`, LogLevel.DEBUG); + done(new Error("Should have thrown")); + }).catch((error) => { + expect(`${error}`).to.include("Cannot create a string longer than"); + done(); + }); + }); + + it("getFileContents too large should return truncated contents", (done: Mocha.Done) => { + mockGetObject(MAX_STRING_LENGTH + 10); + getFileContents({ + filename: testFilename, + s3Folder: UNIT_TEST_KEY_PREFIX + }).then((contents: string | undefined) => { + log(`getFileContents(${testFilename}) result = ${JSON.stringify(contents?.length)}`, LogLevel.DEBUG); + expect(contents).to.not.equal(undefined); + expect(contents?.length).to.equal(MAX_STRING_LENGTH); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("Copy Objects in S3", () => { + const sourceFile: S3File = { + key: `${UNIT_TEST_KEY_PREFIX}/${UNIT_TEST_FILENAME}`, + contentType: "application/json" + }; + const destinationFile: S3File = { + key: `bogus/${UNIT_TEST_FILENAME}`, + contentType: "application/json" + }; + + it("Copy Object should copy object", (done: Mocha.Done) => { + const expectedLastModified = new Date(); + mockCopyObject(expectedLastModified); + copyObject({ sourceFile, destinationFile }).then((result: CopyObjectCommandOutput | undefined) => { + log(`copyObject(${sourceFile.key}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + expect(result?.CopyObjectResult, "CopyObjectResult").to.not.equal(undefined); + expect(result?.CopyObjectResult?.LastModified, "LastModified").to.equal(expectedLastModified); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Copy Files in S3", () => { + const filename: string = UNIT_TEST_FILENAME; + const sourceS3Folder: string = UNIT_TEST_KEY_PREFIX; + const destinationS3Folder: string = "bogus"; + const destinationFilename: string = "bogus"; + + it("Copy File should copy file", (done: Mocha.Done) => { + mockCopyObject(); + copyFile({ filename, sourceS3Folder, destinationS3Folder }).then(() => { + done(); + }).catch((error) => done(error)); + }); + + it("Copy File should change filename", (done: Mocha.Done) => { + const expectedLastModified = new Date(); + mockCopyObject(expectedLastModified); + copyFile({ filename, sourceS3Folder, destinationS3Folder, destinationFilename }).then((lastModified: Date | undefined) => { + expect(lastModified).to.equal(expectedLastModified); + done(); + }).catch((error) => done(error)); + }); + + it("Copy File to same folder should fail", (done: Mocha.Done) => { + mockCopyObject(); + copyFile({ filename, sourceS3Folder, destinationS3Folder: sourceS3Folder }).then(() => { + done("Should have failed"); + }).catch((error) => { + expect(`${error}`).to.include("copyFile cannot copy to itself"); + // This won't be set on failures, set it so the afterEach doesn't throw + healthCheckDate = new Date(); + done(); + }); + }); + + it("Copy File to same filename should fail", (done: Mocha.Done) => { + mockCopyObject(); + copyFile({ filename, sourceS3Folder, destinationS3Folder: sourceS3Folder, destinationFilename: filename }).then(() => { + done("Should have failed"); + }).catch((error) => { + expect(`${error}`).to.include("copyFile cannot copy to itself"); + // This won't be set on failures, set it so the afterEach doesn't throw + healthCheckDate = new Date(); + done(); + }); + }); + }); + + describe("Get Object Tagging in S3", () => { + it("getObjectTagging should get a tag", (done: Mocha.Done) => { + const tags = new Map([["unittest", "true"]]); + mockGetObjectTagging(tags); + getObjectTagging(UNIT_TEST_KEY).then((result: GetObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + expect(result.TagSet).to.not.equal(undefined); + expect(result.TagSet).to.not.equal(undefined); + expect(result.TagSet!.length).to.equal(1); + expect(result.TagSet![0].Key).to.equal("unittest"); + expect(result.TagSet![0].Value).to.equal("true"); + done(); + }).catch((error) => done(error)); + }); + + it("getObjectTagging should get no tags", (done: Mocha.Done) => { + mockGetObjectTagging(undefined); + getObjectTagging(UNIT_TEST_KEY).then((result: GetObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + expect(result.TagSet).to.not.equal(undefined); + expect(result.TagSet).to.not.equal(undefined); + expect(result.TagSet!.length).to.equal(0); + done(); + }).catch((error) => done(error)); + }); + + it("getTags should get a tag", (done: Mocha.Done) => { + const tags = new Map([["unittest", "true"]]); + mockGetObjectTagging(tags); + getTags({ + filename: UNIT_TEST_FILENAME, + s3Folder: UNIT_TEST_KEYSPACE_PREFIX + }).then((result: Map | undefined) => { + expect(result).to.not.equal(undefined); + expect(result?.size).to.equal(1); + expect(result?.get("unittest")).to.equal("true"); + done(); + }).catch((error) => done(error)); + }); + + it("getTags should get no tags", (done: Mocha.Done) => { + mockGetObjectTagging(undefined); + getTags({ + filename: UNIT_TEST_FILENAME, + s3Folder: UNIT_TEST_KEYSPACE_PREFIX + }).then((result: Map | undefined) => { + expect(result).to.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("Put Object Tagging in S3", () => { + const tags = new Map([["unittest", "true"]]); + it("putObjectTagging should not throw", (done: Mocha.Done) => { + putObjectTagging({ key: UNIT_TEST_KEY, tags }).then((result: PutObjectTaggingCommandOutput) => { + expect(result).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + + it("putTags should not throw", (done: Mocha.Done) => { + putTags({ + filename: UNIT_TEST_FILENAME, + s3Folder: UNIT_TEST_KEYSPACE_PREFIX, + tags + }).then(() => { + done(); + }).catch((error) => done(error)); + }); + }); +}); diff --git a/common/test/s3file.spec.ts b/common/test/s3file.spec.ts new file mode 100644 index 00000000..190ee8e8 --- /dev/null +++ b/common/test/s3file.spec.ts @@ -0,0 +1,651 @@ +import { + LogLevel, + PpaasS3File, + PpaasTestId, + S3File, + log, + s3 +} from "../src/index"; +import { + UNIT_TEST_BUCKET_NAME, + UNIT_TEST_FILENAME, + UNIT_TEST_FILEPATH, + UNIT_TEST_KEYSPACE_PREFIX, + UNIT_TEST_LOCAL_FILE_LOCATION, + mockCopyObject, + mockGetObject, + mockGetObjectError, + mockGetObjectTagging, + mockListObject, + mockListObjects, + mockS3, + mockUploadObject, + resetMockS3 +} from "./mock"; +import { PpaasS3FileOptions } from "../src/s3file"; +import { Stats } from "fs"; +import { expect } from "chai"; +import fs from "fs/promises"; +import path from "path"; + +class PpaasS3FileUnitTest extends PpaasS3File { + public constructor (options: PpaasS3FileOptions) { + super(options); + } + + public getLastModifiedLocal (): number { + return this.lastModifiedLocal; + } + + public setLastModifiedLocal (lastModifiedLocal: number) { + this.lastModifiedLocal = lastModifiedLocal; + } + + public setLastModifiedRemote (lastModifiedRemote: Date) { + this.lastModifiedRemote = lastModifiedRemote; + } +} + +describe("PpaasS3File", () => { + // let s3FileKey: string | undefined; + let testPpaasS3FileUpload: PpaasS3FileUnitTest; + let testPpaasS3FileDownload: PpaasS3FileUnitTest; + let fullPpaasS3File: PpaasS3FileUnitTest; + let unitTestKeyPrefix: string; + let fullS3File: Required; + const tags = new Map([["unittest", "true"]]); + const tagsSize = tags.size; + const testFileTags = new Map(s3.defaultTestFileTags()); + const testFileTagsSize = testFileTags.size; + + before (() => { + mockS3(); + s3.setAccessCallback((_date: Date) => {/* no-op */}); + + const ppaasTestId = PpaasTestId.makeTestId(UNIT_TEST_FILENAME); + unitTestKeyPrefix = ppaasTestId.s3Folder; + }); + + beforeEach (() => { + testPpaasS3FileUpload = new PpaasS3FileUnitTest({ + filename: UNIT_TEST_FILENAME, + s3Folder: unitTestKeyPrefix, + localDirectory: path.dirname(UNIT_TEST_FILEPATH), + tags + }); + testPpaasS3FileDownload = new PpaasS3FileUnitTest({ + filename: UNIT_TEST_FILENAME, + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + tags + }); + fullS3File = { + body: "test", + key: testPpaasS3FileDownload.key, + contentEncoding: "encoding", + contentType: testPpaasS3FileDownload.contentType, + publicRead: true, + tags, + storageClass: "storageClass" + }; + fullPpaasS3File = new PpaasS3FileUnitTest({ + filename: UNIT_TEST_FILENAME, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + s3Folder: unitTestKeyPrefix, + publicRead: true, + tags + }); + Object.assign(fullPpaasS3File, fullS3File); + for (const key in fullS3File) { + expect(JSON.stringify(fullPpaasS3File.getS3File()[key as keyof S3File]), "fullPpaasS3File." + key).to.equal(JSON.stringify(fullS3File[key as keyof S3File])); + } + }); + + after (() => { + resetMockS3(); + s3.setAccessCallback(undefined as any); + }); + + describe("Constructor should set the file type", () => { + it("stats.json should be json", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats-test.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.contentType).to.equal("application/json"); + done(); + }); + + it("stats.csv should be csv", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats-test.csv", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.contentType).to.equal("text/csv"); + done(); + }); + + it("stats.yaml should be yaml", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats-test.yaml", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.contentType).to.equal("text/x-yaml"); + done(); + }); + + it("stats.txt should be text", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats-test.txt", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.contentType).to.equal("text/plain"); + done(); + }); + + it("pewpew should be octet-stream", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "pewpew", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.contentType).to.equal("application/octet-stream"); + done(); + }); + + it("should be set public-read", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.publicRead).to.equal(undefined); + done(); + }); + + it("should be set public-read false", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + publicRead: false + }); + expect(ppaasS3File.publicRead).to.equal(false); + done(); + }); + + it("should be set public-read true", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + publicRead: true + }); + expect(ppaasS3File.publicRead).to.equal(true); + done(); + }); + + it("should set default tags", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }); + expect(ppaasS3File.tags).to.not.equal(undefined); + expect(ppaasS3File.tags?.size).to.equal(testFileTagsSize); + expect(JSON.stringify([...(ppaasS3File.tags || [])])).to.equal(JSON.stringify([...testFileTags])); + done(); + }); + + it("should override default tags with empty", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + tags: new Map() + }); + expect(ppaasS3File.tags).to.not.equal(undefined); + expect(ppaasS3File.tags?.size).to.equal(0); + expect(JSON.stringify([...(ppaasS3File.tags || [])])).to.equal(JSON.stringify([])); + done(); + }); + + it("should override default tags with populated", (done: Mocha.Done) => { + const ppaasS3File: PpaasS3File = new PpaasS3File({ + filename: "stats.json", + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + tags + }); + expect(ppaasS3File.tags).to.not.equal(undefined); + expect(ppaasS3File.tags?.size).to.equal(tagsSize); + expect(JSON.stringify([...(ppaasS3File.tags || [])])).to.equal(JSON.stringify([...tags])); + done(); + }); + + }); + + it("getS3File should have all properties of a S3File", (done: Mocha.Done) => { + const actualS3File = fullPpaasS3File.getS3File(); + expect(Object.keys(actualS3File).length, Object.keys(actualS3File).toString() + " length").to.equal(Object.keys(fullS3File).length); + for (const key in actualS3File) { + expect(JSON.stringify(actualS3File[key as keyof S3File]), "actualS3File." + key).to.equal(JSON.stringify(fullS3File[key as keyof S3File])); + } + done(); + }); + + it("sanitizedCopy should only be an extended S3File", (done: Mocha.Done) => { + const extendedKeys = [ + "s3Folder", + "filename", + "localDirectory", + "localFilePath", + "remoteUrl", + "lastModifiedLocal", + "lastModifiedRemote" + ]; + const actualS3File = fullPpaasS3File.sanitizedCopy(); + expect(Object.keys(actualS3File).length, Object.keys(actualS3File).toString() + " length").to.equal(Object.keys(fullS3File).length + extendedKeys.length - 1); + for (const key in actualS3File) { + if (extendedKeys.includes(key)) { + expect(JSON.stringify(actualS3File[key as keyof S3File]), key).to.equal(JSON.stringify((fullPpaasS3File as any)[key as keyof S3File])); + } else { + expect(JSON.stringify(actualS3File[key as keyof S3File]), key).to.equal(JSON.stringify(fullS3File[key as keyof S3File])); + } + } + done(); + }); + + describe("existsLocal should work", () => { + it("testPpaasS3FileUpload should exist local", (done: Mocha.Done) => { + testPpaasS3FileUpload.existsLocal().then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("testPpaasS3FileDownload should not exist local", (done: Mocha.Done) => { + testPpaasS3FileDownload.existsLocal().then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + }); + + describe("List Files in S3", () => { + it("testPpaasS3FileDownload should not exist inS3", (done: Mocha.Done) => { + mockListObjects([]); + testPpaasS3FileUpload.existsInS3().then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + + it("PpaasS3File.existsInS3 should not exist inS3", (done: Mocha.Done) => { + mockListObjects([]); + PpaasS3File.existsInS3(testPpaasS3FileUpload.key).then((exists) => { + expect(exists).to.equal(false); + done(); + }) + .catch((error) => done(error)); + }); + + it("List PpaasS3File should always succeed even if empty", (done: Mocha.Done) => { + mockListObjects([]); + PpaasS3File.getAllFilesInS3({ s3Folder: "bogus", localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("bogus", ${UNIT_TEST_LOCAL_FILE_LOCATION}) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("testPpaasS3FileDownload should exist inS3", (done: Mocha.Done) => { + mockListObject(UNIT_TEST_FILENAME, unitTestKeyPrefix); + testPpaasS3FileUpload.existsInS3().then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("PpaasS3File.existsInS3 should exist inS3", (done: Mocha.Done) => { + mockListObject(UNIT_TEST_FILENAME, unitTestKeyPrefix); + PpaasS3File.existsInS3(testPpaasS3FileUpload.key).then((exists) => { + expect(exists).to.equal(true); + done(); + }) + .catch((error) => done(error)); + }); + + it("getAllFilesInS3 should return files", (done: Mocha.Done) => { + mockListObject(UNIT_TEST_FILENAME, unitTestKeyPrefix); + mockGetObjectTagging(tags); + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix, + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.greaterThan(0); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].remoteUrl).to.not.equal(undefined); + expect(result[0].remoteUrl, `${JSON.stringify(result[0].remoteUrl)}.include("${UNIT_TEST_BUCKET_NAME}")`).to.include(UNIT_TEST_BUCKET_NAME); + const expectedUrl = `/${UNIT_TEST_KEYSPACE_PREFIX}${unitTestKeyPrefix}/${UNIT_TEST_FILENAME}`; + expect(result[0].remoteUrl, `${JSON.stringify(result[0].remoteUrl)}.include("${expectedUrl}")`).to.include(expectedUrl); + expect(result[0].tags).to.not.equal(undefined); + expect(result[0].tags?.size).to.equal(1); + expect(result[0].tags?.has("test")).to.equal(false); + expect(result[0].tags?.get("unittest")).to.equal("true"); + done(); + }).catch((error) => { + log("getAllFilesInS3 should return files error", LogLevel.WARN, error); + done(error); + }); + }); + + it("getAllFilesInS3 partial folder should return files", (done: Mocha.Done) => { + mockListObject(UNIT_TEST_FILENAME, unitTestKeyPrefix); + mockGetObjectTagging(tags); + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.be.greaterThan(0); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].s3Folder).to.equal(unitTestKeyPrefix); + expect(result[0].filename).to.equal(UNIT_TEST_FILENAME); + expect(result[0].remoteUrl).to.not.equal(undefined); + expect(result[0].remoteUrl, `${JSON.stringify(result[0].remoteUrl)}.include("${UNIT_TEST_BUCKET_NAME}")`).to.include(UNIT_TEST_BUCKET_NAME); + const expectedUrl = `/${UNIT_TEST_KEYSPACE_PREFIX}${unitTestKeyPrefix}/${UNIT_TEST_FILENAME}`; + expect(result[0].remoteUrl, `${JSON.stringify(result[0].remoteUrl)}.include("${expectedUrl}")`).to.include(expectedUrl); + expect(result[0].tags).to.not.equal(undefined); + expect(result[0].tags?.size).to.equal(1); + expect(result[0].tags?.has("test")).to.equal(false); + expect(result[0].tags?.get("unittest")).to.equal("true"); + done(); + }).catch((error) => { + log("getAllFilesInS3 partial folder should return files error", LogLevel.WARN, error); + done(error); + }); + }); + + it("getAllFilesInS3 partial folder by extension should return files", (done: Mocha.Done) => { + mockListObject(UNIT_TEST_FILENAME, unitTestKeyPrefix); + mockGetObjectTagging(tags); + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + extension: UNIT_TEST_FILENAME.slice(-3) + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}") result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(1); + // getAllFilesInS3 should set the remote date so we can sort + expect(result[0].getLastModifiedRemote()).to.be.greaterThan(new Date(0)); + expect(result[0].tags).to.not.equal(undefined); + expect(result[0].tags?.size).to.equal(1); + expect(result[0].tags?.has("test")).to.equal(false); + expect(result[0].tags?.get("unittest")).to.equal("true"); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("getAllFilesInS3 partial folder wrong extension should not return files", (done: Mocha.Done) => { + mockListObjects([]); + PpaasS3File.getAllFilesInS3({ + s3Folder: unitTestKeyPrefix.slice(0, -2), + localDirectory: UNIT_TEST_LOCAL_FILE_LOCATION, + extension: "bad", + maxFiles: 1000 + }).then((result: PpaasS3File[]) => { + log(`PpaasS3File.getAllFilesInS3("${unitTestKeyPrefix}", "${UNIT_TEST_LOCAL_FILE_LOCATION}", 1000) result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + expect(result.length).to.equal(0); + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Upload File to S3", () => { + let lastModified: number; + beforeEach (async () => { + try { + const stats: Stats = await fs.stat(testPpaasS3FileUpload.localFilePath); + lastModified = stats.mtimeMs; + testPpaasS3FileUpload.setLastModifiedLocal(0); + testPpaasS3FileUpload.remoteUrl = ""; + // As long as we don't throw, it passes + } catch(error) { + throw error; + } + }); + + it("Upload a test file to S3", (done: Mocha.Done) => { + mockUploadObject({ filename: UNIT_TEST_FILENAME, folder: unitTestKeyPrefix }); + testPpaasS3FileUpload.upload().then(() => { + log("testPpaasS3FileUpload.upload succeeded}", LogLevel.DEBUG); + // we should upload it and update the time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + // Hasn't been downloaded so it shouldn't be set + expect(testPpaasS3FileUpload.getLastModifiedRemote().getTime()).to.equal(new Date(0).getTime()); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Upload a test file should upload changed files", (done: Mocha.Done) => { + mockUploadObject({ filename: UNIT_TEST_FILENAME, folder: unitTestKeyPrefix }); + testPpaasS3FileUpload.setLastModifiedLocal(lastModified - 1000); + testPpaasS3FileUpload.upload().then(() => { + log("testPpaasS3FileDownload.upload() succeeded", LogLevel.DEBUG); + // If it's older we should upload it and update the time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + done(); + }).catch((error) => done(error)); + }); + + it("Upload a test file should not upload unchanged files", (done: Mocha.Done) => { + mockUploadObject({ filename: UNIT_TEST_FILENAME, folder: unitTestKeyPrefix }); + testPpaasS3FileUpload.setLastModifiedLocal(lastModified); // It checks exact + testPpaasS3FileUpload.upload().then(() => { + log("testPpaasS3FileDownload.upload() succeeded", LogLevel.DEBUG); + // If it's newer we should not upload it and keep the same time + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.equal(""); + done(); + }).catch((error) => done(error)); + }); + + it("Upload a test file force should upload unchanged files", (done: Mocha.Done) => { + mockUploadObject({ filename: UNIT_TEST_FILENAME, folder: unitTestKeyPrefix }); + testPpaasS3FileUpload.setLastModifiedLocal(lastModified); + testPpaasS3FileUpload.upload(true).then(() => { + log("testPpaasS3FileDownload.upload(true) succeeded", LogLevel.DEBUG); + // If it's newer, but forced we should upload it and set the time to last modified + expect(testPpaasS3FileUpload.getLastModifiedLocal()).to.equal(lastModified); + expect(testPpaasS3FileUpload.remoteUrl).to.not.equal(""); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("Get Files in S3", () => { + let lastModified: Date; + const testFilename: string = path.basename(UNIT_TEST_FILEPATH); + let localFile: string | undefined; + + beforeEach ( () => { + lastModified = new Date(); // Set the time to now + }); + + afterEach (async () => { + // Delete the local file + if(localFile) { + await fs.unlink(localFile) + .catch((error) => log("Could not delete " + localFile, LogLevel.WARN, error)); + } + }); + + it("Get File should return files", (done: Mocha.Done) => { + mockGetObject(); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + localFile = testPpaasS3FileDownload.localFilePath; + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + + it("Get File should return changed files", (done: Mocha.Done) => { + mockGetObject("test", "application/json", lastModified); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + // Set it before the last modified so it's changed + testPpaasS3FileDownload.setLastModifiedRemote(new Date(lastModified.getTime() - 1000)); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should not be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = testPpaasS3FileDownload.localFilePath; + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + + it("Get File should not return unchanged files", (done: Mocha.Done) => { + mockGetObjectError(304); + // Set it to the last modified so it's unchanged + testPpaasS3FileDownload.setLastModifiedRemote(lastModified); + testPpaasS3FileDownload.download().then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = undefined; + fs.stat(testPpaasS3FileDownload.localFilePath).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + expect(stats).to.equal(undefined); + done(); + }).catch((_error) => { + // We shouldn't have a stats object + done(); + }); + }).catch((error) => done(error)); + }); + + it("Get File force should return unchanged files", (done: Mocha.Done) => { + mockGetObject("test", "application/json", lastModified); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + // Set it to the last modified so it's unchanged + testPpaasS3FileDownload.setLastModifiedRemote(lastModified); + // Then force download it + testPpaasS3FileDownload.download(true).then((result: string) => { + log(`testPpaasS3FileDownload.download() result = ${result}`, LogLevel.DEBUG); + expect(result).to.not.equal(undefined); + // The time should not be updated + expect(testPpaasS3FileDownload.getLastModifiedRemote().getTime()).to.equal(lastModified.getTime()); + localFile = testPpaasS3FileDownload.localFilePath; + fs.stat(localFile).then((stats: Stats) => { + log(`fs.stat(${testFilename}) stats = ${JSON.stringify(stats)}`, LogLevel.DEBUG); + // We should have a stats object + expect(stats).to.not.equal(undefined); + done(); + }).catch((error) => done(error)); + }).catch((error) => done(error)); + }); + }); + + describe("Copy Files in S3", () => { + let lastModified: Date; + let destinationS3Folder: string; + const destinationFilename = "changed.txt"; + + before (() => { + mockS3(); + const ppaasTestId = PpaasTestId.makeTestId(UNIT_TEST_FILENAME); + destinationS3Folder = ppaasTestId.s3Folder; + }); + + beforeEach ( () => { + lastModified = new Date(); // Set the time to now + }); + + it("Caopy a test file to S3", (done: Mocha.Done) => { + mockCopyObject(lastModified); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + testPpaasS3FileDownload.copy({ destinationS3Folder }).then((copiedPpaasS3File: PpaasS3File) => { + log("testPpaasS3FileUpload.copy succeeded}", LogLevel.DEBUG); + expect(copiedPpaasS3File.getLastModifiedRemote()).to.equal(lastModified); + expect(copiedPpaasS3File.s3Folder).to.equal(destinationS3Folder); + expect(copiedPpaasS3File.filename).to.equal(testPpaasS3FileDownload.filename); + expect(copiedPpaasS3File.localDirectory).to.equal(testPpaasS3FileDownload.localDirectory); + expect(copiedPpaasS3File.publicRead).to.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Copy and change the name of a test file in S3", (done: Mocha.Done) => { + mockCopyObject(lastModified); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + testPpaasS3FileDownload.copy({ destinationS3Folder, destinationFilename }).then((copiedPpaasS3File: PpaasS3File) => { + log("testPpaasS3FileUpload.copy succeeded}", LogLevel.DEBUG); + expect(copiedPpaasS3File.getLastModifiedRemote()).to.equal(lastModified); + expect(copiedPpaasS3File.s3Folder).to.equal(destinationS3Folder); + expect(copiedPpaasS3File.filename).to.equal(destinationFilename); + expect(copiedPpaasS3File.localDirectory).to.equal(testPpaasS3FileDownload.localDirectory); + expect(copiedPpaasS3File.publicRead).to.equal(undefined); + done(); + }).catch((error) => { + done(error); + }); + }); + + it("Copy and make readable", (done: Mocha.Done) => { + mockCopyObject(lastModified); + mockGetObjectTagging(testPpaasS3FileDownload.tags); + testPpaasS3FileDownload.copy({ destinationS3Folder, publicRead: true }).then((copiedPpaasS3File: PpaasS3File) => { + log("testPpaasS3FileUpload.copy succeeded}", LogLevel.DEBUG); + expect(copiedPpaasS3File.getLastModifiedRemote()).to.equal(lastModified); + expect(copiedPpaasS3File.s3Folder).to.equal(destinationS3Folder); + expect(copiedPpaasS3File.filename).to.equal(testPpaasS3FileDownload.filename); + expect(copiedPpaasS3File.localDirectory).to.equal(testPpaasS3FileDownload.localDirectory); + expect(copiedPpaasS3File.publicRead).to.equal(true); + done(); + }).catch((error) => { + done(error); + }); + }); + + }); +}); diff --git a/common/test/s3test.txt b/common/test/s3test.txt new file mode 100644 index 00000000..1a260539 --- /dev/null +++ b/common/test/s3test.txt @@ -0,0 +1 @@ +{"message":"Test file to upload."} \ No newline at end of file diff --git a/common/test/sqs.spec.ts b/common/test/sqs.spec.ts new file mode 100644 index 00000000..f34d88fe --- /dev/null +++ b/common/test/sqs.spec.ts @@ -0,0 +1,707 @@ +import { + ChangeMessageVisibilityCommandInput, + DeleteMessageCommandInput, + GetQueueAttributesCommandInput, + GetQueueAttributesCommandOutput, + MessageAttributeValue, + ReceiveMessageCommandInput, + ReceiveMessageCommandOutput, + Message as SQSMessage, + SendMessageCommandInput, + SendMessageCommandOutput +} from "@aws-sdk/client-sqs"; +import { LogLevel, SqsQueueType, log } from "../src/index"; +import { + QUEUE_URL_COMMUNICATION, + QUEUE_URL_SCALE_IN, + QUEUE_URL_TEST, + changeMessageVisibility, + changeMessageVisibilityByHandle, + deleteMessage, + deleteMessageByHandle, + deleteTestScalingMessage, + getCommunicationMessage, + getNewTestToRun, + getQueueAttributes, + getQueueAttributesMap, + getQueueUrl, + getTestScalingMessage, + receiveMessage, + refreshTestScalingMessage, + sendMessage, + sendNewCommunicationsMessage, + sendNewTestToRun, + sendTestScalingMessage, + setAccessCallback +} from "../src/util/sqs"; +import { + mockGetQueueAttributes, + mockReceiveMessageAttributes, + mockReceiveMessages, + mockSendMessage, + mockSqs, + resetMockSqs +} from "./mock"; +import { expect } from "chai"; + + +describe("SqsUtil", () => { + let expectedQueueUrlTest: string; + let expectedQueueUrlTestName: string; + let expectedQueueUrlScale: string; + let expectedQueueUrlScaleName: string; + const receiveParamsTest: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 30, + WaitTimeSeconds: 10 + }; + + const receiveParamsScale: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 0, // Don't lock anything out in the real queue + WaitTimeSeconds: 0 + }; + + const receiveParamsComm: ReceiveMessageCommandInput = { + AttributeNames: [ + "All" + ], + MaxNumberOfMessages: 1, + MessageAttributeNames: [ + "All" + ], + QueueUrl: "", + VisibilityTimeout: 5, + WaitTimeSeconds: 0 + }; + let healthCheckDate: Date | undefined; + + before(() => { + mockSqs(); + log("QUEUE_URL_TEST=" + [...QUEUE_URL_TEST], LogLevel.DEBUG); + log("QUEUE_URL_SCALE=" + [...QUEUE_URL_SCALE_IN], LogLevel.DEBUG); + log("QUEUE_URL_COMMUNICATION=" + QUEUE_URL_COMMUNICATION, LogLevel.DEBUG); + // Can't set these until after init. + expectedQueueUrlTest = receiveParamsTest.QueueUrl = QUEUE_URL_TEST.values().next().value; + expect(typeof expectedQueueUrlTest).to.equal("string"); + expect(expectedQueueUrlTest.length).to.be.greaterThan(0); + expectedQueueUrlTestName = QUEUE_URL_TEST.keys().next().value; + expect(typeof expectedQueueUrlTestName).to.equal("string"); + expect(expectedQueueUrlTestName.length).to.be.greaterThan(0); + expectedQueueUrlScale = receiveParamsScale.QueueUrl = QUEUE_URL_SCALE_IN.values().next().value; + expect(typeof expectedQueueUrlScale).to.equal("string"); + expect(expectedQueueUrlScale.length).to.be.greaterThan(0); + expectedQueueUrlScaleName = QUEUE_URL_SCALE_IN.keys().next().value; + expect(typeof expectedQueueUrlScaleName).to.equal("string"); + expect(expectedQueueUrlScaleName.length).to.be.greaterThan(0); + receiveParamsComm.QueueUrl = QUEUE_URL_COMMUNICATION; + log("queueUrl", LogLevel.DEBUG, { expectedQueueUrlScale, expectedQueueUrlScaleName, expectedQueueUrlTest, expectedQueueUrlTestName }); + setAccessCallback((date: Date) => healthCheckDate = date); + }); + + after(() => { + // Reset the mock + resetMockSqs(); + }); + + describe("getQueueUrl", () => { + it("Should return communications", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Communications)).to.equal(QUEUE_URL_COMMUNICATION); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should return test default", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Test)).to.equal(expectedQueueUrlTest); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should return scale default", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Scale)).to.equal(expectedQueueUrlScale); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should return test named", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Test, expectedQueueUrlTestName)).to.equal(expectedQueueUrlTest); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should return scale named", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Scale, expectedQueueUrlScaleName)).to.equal(expectedQueueUrlScale); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should ignore communications named", (done: Mocha.Done) => { + try { + expect(getQueueUrl(SqsQueueType.Communications, "invalid")).to.equal(QUEUE_URL_COMMUNICATION); + done(); + } catch (error) { + log("getQueueUrl error", LogLevel.ERROR, error); + done(error); + } + }); + + it("Should error on test invalid named", (done: Mocha.Done) => { + try { + const queueUrl = getQueueUrl(SqsQueueType.Test, "invalid"); + done(new Error("invalid named should throw, got " + JSON.stringify(queueUrl))); + } catch (error) { + log("getQueueUrl error", LogLevel.DEBUG, error); + expect(`${error}`).to.include("No such Queue found"); + done(); + } + }); + + it("Should error on scale invalid named", (done: Mocha.Done) => { + try { + const queueUrl = getQueueUrl(SqsQueueType.Scale, "invalid"); + done(new Error("invalid named should throw, got " + JSON.stringify(queueUrl))); + } catch (error) { + log("getQueueUrl error", LogLevel.DEBUG, error); + expect(`${error}`).to.include("No such Queue found"); + done(); + } + }); + }); + + describe("SQS Read/Write", () => { + beforeEach( () => { + // Set the access callback back undefined + healthCheckDate = undefined; + }); + + afterEach ( () => { + // If this is still undefined the access callback failed and was not updated with the last access date + log("afterEach healthCheckDate=" + healthCheckDate, healthCheckDate ? LogLevel.DEBUG : LogLevel.ERROR); + expect(healthCheckDate).to.not.equal(undefined); + }); + + describe("Read From Test Retrieval SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + mockReceiveMessages(undefined); + receiveMessage(receiveParamsTest).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Read From Scale In SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + mockReceiveMessages(undefined); + receiveMessage(receiveParamsScale).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Read From Communications Retrieval SQS Queue", () => { + it("ReceiveMessage should always succeed even if empty", (done: Mocha.Done) => { + mockReceiveMessages(undefined); + receiveMessage(receiveParamsComm).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + // As long as we don't throw, it passes + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Actual Messages From Communications Retrieval SQS Queue", () => { + const testId: string = "UnitTest" + Date.now(); // Used to identify OUR Unit test message + let messageHandle: string | undefined; + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: testId + }, + Recipient: { + DataType: "String", + StringValue: "UnitTest" + }, + Sender: { + DataType: "String", + StringValue: "UnitTest" + }, + Message: { + DataType: "String", + StringValue: "UnitTest" + } + }; + + before (async () => { + const sqsMessageRequest: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: "Sending Message to the Communications Queue", + QueueUrl: QUEUE_URL_COMMUNICATION + }; + log("sendMessage request", LogLevel.DEBUG, sqsMessageRequest); + mockSendMessage(); + await sendMessage(sqsMessageRequest); + }); + + after (async () => { + if (messageHandle) { + const sqsDeleteRequest: DeleteMessageCommandInput = { + ReceiptHandle: messageHandle, + QueueUrl: QUEUE_URL_COMMUNICATION + }; + log("deleteMessage request", LogLevel.DEBUG, sqsDeleteRequest); + await deleteMessage(sqsDeleteRequest); + } + }); + + it("ReceiveMessage Communications should receive a message", (done: Mocha.Done) => { + mockReceiveMessageAttributes(messageAttributes); + receiveMessage(receiveParamsComm).then((result: ReceiveMessageCommandOutput) => { + log("receiveMessage result", LogLevel.DEBUG, result); + expect(result, "receiveMessage result " + JSON.stringify(result)).to.not.equal(undefined); + expect(result.Messages && result.Messages.length, "receiveMessage result length " + JSON.stringify(result)).to.be.greaterThan(0); + // But we need to grab the handle for clean-up + if (result && result.Messages && result.Messages.length > 0) { + for (const message of result.Messages) { + const receivedAttributes: Record | undefined = message.MessageAttributes; + if (receivedAttributes && Object.keys(receivedAttributes).includes("TestId") && receivedAttributes["TestId"].StringValue === testId) { + messageHandle = message.ReceiptHandle; + } + } + } + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Actual Communications From Communications Retrieval SQS Queue", () => { + const testId: string = "UnitTest" + Date.now(); // Used to identify OUR Unit test message + let messageHandle: string | undefined; + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: testId + }, + Recipient: { + DataType: "String", + StringValue: "UnitTest" + }, + Sender: { + DataType: "String", + StringValue: "UnitTest" + }, + Message: { + DataType: "String", + StringValue: "UnitTest" + } + }; + + before (async () => { + log("sendNewCommunicationsMessage request", LogLevel.DEBUG, messageAttributes); + mockSendMessage(); + await sendNewCommunicationsMessage(messageAttributes); + }); + + after (async () => { + if (messageHandle) { + await deleteMessageByHandle({ messageHandle, sqsQueueType: SqsQueueType.Communications }); + } + }); + + it("getCommunicationMessages should receive a message", (done: Mocha.Done) => { + mockReceiveMessageAttributes(messageAttributes); + getCommunicationMessage().then((message: SQSMessage | undefined) => { + log("getCommunicationMessages result", LogLevel.DEBUG, message); + expect(message, "getCommunicationMessages result " + JSON.stringify(message)).to.not.equal(undefined); + // But we need to grab the handle for clean-up + messageHandle = message!.ReceiptHandle; + done(); + }).catch((error) => { + done(error); + }); + }); + }); + + describe("Send to Real SQS Test Queue", () => { + const messageAttributes: Record = { + UnitTestMessage: { + DataType: "String", + StringValue: "true" + }, + TestId: { + DataType: "String", + StringValue: "UnitTest" + Date.now() + }, + S3Folder: { + DataType: "String", + StringValue: "unittest" + }, + YamlFile: { + DataType: "String", + StringValue: "unittest.yaml" + }, + TestRunTime: { + DataType: "String", + StringValue: "1" + } + }; + + it("SendMessage should succeed", (done: Mocha.Done) => { + const realSendParams: SendMessageCommandInput = { + MessageAttributes: messageAttributes, + MessageBody: "Integration Test", + QueueUrl: receiveParamsTest.QueueUrl + }; + log("Send Test request", LogLevel.DEBUG, realSendParams); + // Start the receive, and while it's waiting, send the message + mockReceiveMessageAttributes(messageAttributes); + receiveMessage(receiveParamsTest).then((result: ReceiveMessageCommandOutput) => { + log(`receiveMessage result = ${JSON.stringify(result)}`, LogLevel.DEBUG); + // As long as we don't throw, it passes + if (result && result.Messages && result.Messages.length > 0) { + const message = result.Messages[0]; + expect(message.MessageAttributes).to.not.equal(undefined); + expect(Object.keys(message.MessageAttributes!)).to.include("UnitTestMessage"); + if (message.ReceiptHandle) { + const changeMessageVisibilityRequest: ChangeMessageVisibilityCommandInput = { + QueueUrl: receiveParamsTest.QueueUrl, + VisibilityTimeout: receiveParamsTest.VisibilityTimeout!, + ReceiptHandle: message.ReceiptHandle + }; + changeMessageVisibility(changeMessageVisibilityRequest).then(() => { + const params: DeleteMessageCommandInput = { + QueueUrl: QUEUE_URL_TEST.values().next().value, + ReceiptHandle: message.ReceiptHandle! + }; + deleteMessage(params).then(() => { + log("deleteMessage Success", LogLevel.DEBUG); + done(); + }).catch((error) => { + log("deleteMessage Error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("changeMessageVisibility Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + } else { + done(); + } + }).catch((error) => { + log("receiveMessage", LogLevel.ERROR, error); + done(error); + }); + // This send is asynchronous from the receive above + mockSendMessage(); + sendMessage(realSendParams) + .then((result: SendMessageCommandOutput) => { + log("sendMessage Success: " + result.MessageId, LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.MessageId).to.not.equal(undefined); + }) + .catch((error) => { + log("sendMessage Error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("sendNewTestToRun should succeed", (done: Mocha.Done) => { + log("Send Test attributes", LogLevel.DEBUG, messageAttributes); + // Start the receive, and while it's waiting, send the message + mockReceiveMessageAttributes(messageAttributes); + getNewTestToRun().then((message: SQSMessage | undefined) => { + log(`getNewTestToRun result = ${JSON.stringify(message)}`, LogLevel.DEBUG); + // As long as we don't throw, it passes + if (message && message.ReceiptHandle) { + expect(message.MessageAttributes).to.not.equal(undefined); + expect(Object.keys(message.MessageAttributes!)).to.include("UnitTestMessage"); + const params = { + messageHandle: message.ReceiptHandle, + sqsQueueType: SqsQueueType.Test + }; + changeMessageVisibilityByHandle(params).then(() => { + deleteMessageByHandle(params).then(() => { + log("deleteMessageByHandle Success", LogLevel.DEBUG); + done(); + }).catch((error) => { + log("deleteMessageByHandle Error", LogLevel.ERROR, error); + done(error); + }); + }).catch((error) => { + log("changeMessageVisibilityByHandle Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + }).catch((error) => { + done(error); + }); + // This send is asynchronous from the receive above + mockSendMessage(); + sendNewTestToRun(messageAttributes, expectedQueueUrlTestName) + .then((messageId: string | undefined) => { + log("Send Test Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + }).catch((err) => { + log("Send Test Error", LogLevel.ERROR, err); + done(err); + }); + }); + }); + + describe("Send to Real SQS Scale Queue", () => { + const messageAttributes: Record = { + Scale: { + StringValue: "test", + DataType: "String" + } + }; + it("sendTestScalingMessage should succeed", (done: Mocha.Done) => { + // Start the receive, and while it's waiting, send the message + mockReceiveMessageAttributes(messageAttributes); + getTestScalingMessage().then((message: SQSMessage | undefined) => { + log(`getTestScalingMessage result = ${JSON.stringify(message)}`, LogLevel.DEBUG); + // As long as we don't throw, it passes + if (message && message.ReceiptHandle && message.MessageAttributes && Object.keys(message.MessageAttributes).includes("Scale")) { + deleteMessageByHandle({ messageHandle: message.ReceiptHandle, sqsQueueType: SqsQueueType.Scale }).then(() => { + log("deleteMessageByHandle Success", LogLevel.DEBUG); + done(); + }) + .catch((error) => { + log("deleteMessageByHandle Error", LogLevel.ERROR, error); + done(error); + }); + } else { + done(); + } + }).catch((error) => { + log("getTestScalingMessage Error", LogLevel.ERROR, error); + done(error); + }); + // This send is asynchronous from the receive above + mockSendMessage(); + sendTestScalingMessage() + .then((messageId: string | undefined) => { + log("Send Scale Success: " + messageId, LogLevel.DEBUG, messageId); + expect(messageId).to.not.equal(undefined); + }).catch((err) => { + log("Send Scale Error", LogLevel.ERROR, err); + done(err); + }); + }); + + it("refreshTestScalingMessage should succeed if empty", (done: Mocha.Done) => { + mockReceiveMessages(); + refreshTestScalingMessage().then((result: string | undefined) => { + log(`refreshTestScalingMessage result = ${result}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + done(); + }).catch((error) => { + log("refreshTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("refreshTestScalingMessage should succeed if has 1", (done: Mocha.Done) => { + mockReceiveMessageAttributes(messageAttributes); + refreshTestScalingMessage().then((result: string | undefined) => { + log(`refreshTestScalingMessage result = ${result}`, LogLevel.DEBUG); + expect(result, "result").to.not.equal(undefined); + done(); + }).catch((error) => { + log("refreshTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("deleteTestScalingMessage should succeed if empty", (done: Mocha.Done) => { + mockReceiveMessages(); + deleteTestScalingMessage().then((dmessageId: string | undefined) => { + expect(dmessageId, "dmessageId").to.equal(undefined); + done(); + }).catch((error) => { + log("deleteTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("deleteTestScalingMessage should succeed if has 1", (done: Mocha.Done) => { + mockReceiveMessageAttributes(messageAttributes); + deleteTestScalingMessage().then((dmessageId: string | undefined) => { + expect(dmessageId, "dmessageId").to.not.equal(undefined); + done(); + }).catch((error) => { + log("deleteTestScalingMessage error", LogLevel.ERROR, error); + done(error); + }); + }); + }); + }); + + describe("SQS getQueueAttributes", () => { + it("should getQueueAttributes with test params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_TEST.values().next().value, + AttributeNames: ["All"] + }; + mockGetQueueAttributes(); + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributes with scale params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_SCALE_IN.values().next().value, + AttributeNames: ["All"] + }; + mockGetQueueAttributes(); + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributes with communications params", (done: Mocha.Done) => { + const params: GetQueueAttributesCommandInput = { + QueueUrl: QUEUE_URL_COMMUNICATION, + AttributeNames: ["All"] + }; + mockGetQueueAttributes(); + getQueueAttributes(params).then((result: GetQueueAttributesCommandOutput) => { + log("getQueueAttributes() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result.Attributes).to.not.equal(undefined); + expect(result.Attributes!.QueueArn).to.not.equal(undefined); + expect(result.Attributes!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result.Attributes!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributes() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with test params", (done: Mocha.Done) => { + mockGetQueueAttributes(); + getQueueAttributesMap(SqsQueueType.Test, expectedQueueUrlTestName).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with scale params", (done: Mocha.Done) => { + mockGetQueueAttributes(); + getQueueAttributesMap(SqsQueueType.Scale, expectedQueueUrlScaleName).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("should getQueueAttributesMap with communications params", (done: Mocha.Done) => { + mockGetQueueAttributes(); + getQueueAttributesMap(SqsQueueType.Communications).then((result: Record | undefined) => { + log("getQueueAttributesMap() result", LogLevel.DEBUG, result); + expect(result).to.not.equal(undefined); + expect(result!.QueueArn).to.not.equal(undefined); + expect(result!.ApproximateNumberOfMessages).to.not.equal(undefined); + expect(isNaN(parseInt(result!.ApproximateNumberOfMessages, 10))).to.equal(false); + done(); + }).catch((error) => { + log("getQueueAttributesMap() error", LogLevel.ERROR, error); + done(error); + }); + }); + }); +}); diff --git a/common/test/util.spec.ts b/common/test/util.spec.ts new file mode 100644 index 00000000..85d09e35 --- /dev/null +++ b/common/test/util.spec.ts @@ -0,0 +1,139 @@ +import { expect } from "chai"; +import { util } from "../src/index"; + +const { + CONTROLLER_ENV, + CONTROLLER_APPLICATION_PREFIX, + PREFIX_DEFAULT, + createStatsFileName, + getLocalIpAddress, + getPrefix, + poll, + sleep +} = util; + +describe("Util", () => { + describe("getPrefix", () => { + it("Should default to the agent", (done: Mocha.Done) => { + const prefix: string = getPrefix(undefined); + expect(prefix).to.equal(PREFIX_DEFAULT); + done(); + }); + + it("Should override to the controller", (done: Mocha.Done) => { + const prefix: string = getPrefix(true); + expect(prefix).to.equal(CONTROLLER_ENV ? `${CONTROLLER_APPLICATION_PREFIX}${CONTROLLER_ENV.toUpperCase()}` : PREFIX_DEFAULT); + done(); + }); + + it("Should override to the controller", (done: Mocha.Done) => { + const controllerEnv = "override"; + const prefix: string = getPrefix(controllerEnv); + expect(prefix).to.equal(CONTROLLER_APPLICATION_PREFIX + controllerEnv.toUpperCase()); + done(); + }); + }); + + describe("createStatsFileName", () => { + it("should have the testId in the name", (done: Mocha.Done) => { + const testId: string = "unittest"; + const filename: string = createStatsFileName(testId); + expect(filename).to.include("stats"); + expect(filename).to.include(testId); + done(); + }); + + it("should have the iteration in the name", (done: Mocha.Done) => { + const testId: string = "unittest"; + const filename: string = createStatsFileName(testId, 5000); + expect(filename).to.include("stats"); + expect(filename).to.include(testId); + expect(filename).to.include("5000"); + done(); + }); + }); + + describe("getLocalIpAddress", () => { + it("should be an ipv4 address", (done: Mocha.Done) => { + const ipmatch: RegExp = /^\d+\.\d+\.\d+\.\d+$/; + const ipaddress: string = getLocalIpAddress(); + expect(ipmatch.test(ipaddress), ipaddress + " is ipv4").to.equal(true); + done(); + }); + + if (!process.env.TRAVIS) { + it("should be an ipv6 address", (done: Mocha.Done) => { + // eslint-disable-next-line no-useless-escape + const ipmatch: RegExp = /^[a-z0-9]+\:\:[a-z0-9]+\:[a-z0-9]+\:[a-z0-9]+\:/; + const ipaddress: string = getLocalIpAddress(6); + expect(ipmatch.test(ipaddress), ipaddress + " is ipv6").to.equal(true); + done(); + }); + } + + it("should be a hostname address", (done: Mocha.Done) => { + const ipmatch: RegExp = /^\d+\.\d+\.\d+\.\d+$/; + const ipaddress: string = getLocalIpAddress(2 as any); + expect(ipmatch.test(ipaddress), ipaddress + " is not ipv4").to.equal(false); + expect(/\w/.test(ipaddress), ipaddress + " has a letter").to.equal(true); + done(); + }); + }); + + describe("poll", () => { + it("should poll until finished", (done: Mocha.Done) => { + const timeBefore: number = Date.now(); + let counter: number = 0; + // eslint-disable-next-line require-await + poll(async (): Promise => ++counter > 1, 300, (errMsg: string) => errMsg).then((result: boolean) => { + const timeAfter: number = Date.now(); + expect(counter).to.equal(2); + expect(result).to.equal(true); + expect(timeAfter - timeBefore).to.be.lessThan(300); + done(); + }).catch((error) => done(error)); + }); + + it("should poll until timeout", (done: Mocha.Done) => { + const timeBefore: number = Date.now(); + let counter: number = 0; + // eslint-disable-next-line require-await + poll(async (): Promise => ++counter > 10, 300, (errMsg: string) => errMsg).then((_result: boolean) => { + done(new Error("Should have timed out")); + }).catch((error) => { + const timeAfter: number = Date.now(); + try { + expect(counter).to.equal(3); + expect(`${error}`).to.equal("Error: Promise timed out after 300ms."); + expect(timeAfter - timeBefore).to.be.greaterThan(298); + expect(timeAfter - timeBefore).to.be.lessThan(400); + done(); + } catch (error) { + done(error); + } + }); + }); + }); + + describe("sleep", () => { + it("should sleep for 100ms", (done: Mocha.Done) => { + const startTime: number = Date.now(); + sleep(100).then(() => { + const endTime: number = Date.now(); + expect(endTime - startTime).to.be.greaterThan(98); + expect(endTime - startTime).to.be.lessThan(150); + done(); + }).catch((error) => done(error)); + }); + + it("should sleep for 300ms", (done: Mocha.Done) => { + const startTime: number = Date.now(); + sleep(300).then(() => { + const endTime: number = Date.now(); + expect(endTime - startTime).to.be.greaterThan(298); + expect(endTime - startTime).to.be.lessThan(350); + done(); + }).catch((error) => done(error)); + }); + }); +}); diff --git a/common/test/yamlparser.spec.ts b/common/test/yamlparser.spec.ts new file mode 100644 index 00000000..1d3eb4ea --- /dev/null +++ b/common/test/yamlparser.spec.ts @@ -0,0 +1,179 @@ +import { YamlParser } from "../src/index"; +import { expect } from "chai"; +import path from "path"; + +const UNIT_TEST_FOLDER = process.env.UNIT_TEST_FOLDER || "test"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const BAD_FILEPATH = path.join(UNIT_TEST_FOLDER, "s3test.txt"); +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const EMPTY_FILEPATH = path.join(UNIT_TEST_FOLDER, "empty.yaml"); +const BASIC_FILEPATH = path.join(UNIT_TEST_FOLDER, "basic.yaml"); +const BASIC_FILEPATH_WITH_VARS = path.join(UNIT_TEST_FOLDER, "basicwithvars.yaml"); +const BASIC_FILEPATH_WITH_ENV = path.join(UNIT_TEST_FOLDER, "basicwithenv.yaml"); +const BASIC_FILEPATH_WITH_FILES = path.join(UNIT_TEST_FOLDER, "basicwithfiles.yaml"); +const BASIC_FILEPATH_NO_PEAK_LOAD = path.join(UNIT_TEST_FOLDER, "basicnopeakload.yaml"); +const BASIC_FILEPATH_HEADERS_ALL = path.join(UNIT_TEST_FOLDER, "basicnopeakload.yaml"); + +describe("YamlParser", () => { + describe("parseYamlFile should throw on non-existant files", () => { + it("doesnotexist.yaml should not exist", (done: Mocha.Done) => { + YamlParser.parseYamlFile("doesnotexist.yaml", {}) + .then((_yamlParser: YamlParser) => done(new Error("This file Should not exist"))) + .catch((error) => { + expect(error).to.not.equal(undefined); + expect(error.code, JSON.stringify(error)).to.equal("ENOENT"); + done(); + }); + }); + }); + + describe("parseYamlFile should throw on invalid files", () => { + it(BAD_FILEPATH + " should throw", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BAD_FILEPATH, {}) + .then((_yamlParser: YamlParser) => done(new Error("This should not parse"))) + .catch((error) => { + try { + expect(`${error}`).to.include("UnrecognizedKey"); + } catch (err) { + done(err); + return; + } + done(); + }); + }); + + it(EMPTY_FILEPATH + " should be invalid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(EMPTY_FILEPATH, {}) + .then((_yamlParser: YamlParser) => done(new Error("This should not parse"))) + .catch((error) => { + try { + expect(`${error}`).to.include("YamlDeserialize"); + } catch (err) { + done(err); + return; + } + done(); + }); + }); + + it(BASIC_FILEPATH_WITH_ENV + " should be invalid without our env variable", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_WITH_ENV, {}) + .then((_yamlParser: YamlParser) => done(new Error("This should not parse"))) + .catch((error) => { + try { + expect(`${error}`).to.include("missingEnvironmentVariables=SERVICE_URL_AGENT,TEST"); + } catch (err) { + done(err); + return; + } + done(); + }); + }); + }); + + describe("parseYamlFile should parse valid files", () => { + it(BASIC_FILEPATH + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH, {}) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH_WITH_VARS + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_WITH_VARS, {}) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH + " should be valid with extra variables", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH, { NOT_NEEDED: "true", ALSO_NOT_NEEDED: "false" }) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH_WITH_ENV + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_WITH_ENV, { SERVICE_URL_AGENT: "127.0.0.1", TEST: "true" }) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH_WITH_ENV + " should be valid with extra variables", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_WITH_ENV, { SERVICE_URL_AGENT: "127.0.0.1", TEST: "true", NOT_NEEDED: "true", ALSO_NOT_NEEDED: "false" }) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + }); + + it(BASIC_FILEPATH_WITH_FILES + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_WITH_FILES, { SPLUNK_PATH: UNIT_TEST_FOLDER }) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(1); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(2); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH_NO_PEAK_LOAD + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_NO_PEAK_LOAD, {}) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); + + it(BASIC_FILEPATH_HEADERS_ALL + " should be valid", (done: Mocha.Done) => { + YamlParser.parseYamlFile(BASIC_FILEPATH_HEADERS_ALL, {}) + .then((yamlParser: YamlParser) => { + expect(yamlParser).to.not.equal(undefined); + expect(yamlParser.getBucketSizeMs(), "getBucketSizeMs").to.equal(60000); + expect(yamlParser.getTestRunTimeMn(), "getTestRunTimeMn").to.equal(2); + expect(yamlParser.getInputFileNames().length, "getInputFileNames().length").to.equal(0); + expect(yamlParser.getLoggerFileNames().length, "getLoggerFileNames().length").to.equal(0); + done(); + }) + .catch((error) => done(error)); + }); +}); diff --git a/common/tsconfig.json b/common/tsconfig.json new file mode 100644 index 00000000..58b892bc --- /dev/null +++ b/common/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "plugins": [{ + "name": "tslint-language-service" + }], + "target": "es2022", + "module": "commonjs", + "declaration": true, + "moduleResolution": "node", + "isolatedModules": true, + "jsx": "react", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitUseStrict": false, + "removeComments": false, + "noLib": false, + "preserveConstEnums": true, + "suppressImplicitAnyIndexErrors": false, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, + "sourceMap": true, + "outDir": "dist", + "baseUrl": "src", + "lib": ["es2021", "esnext.asynciterable", "dom", "dom.iterable"] + }, + "exclude": ["node_modules"], + "include": ["src/**/*", "types/**/*", "test/**/*", "integration/**/*"], + "compileOnSave": true, + "buildOnSave": false +} diff --git a/common/tsconfig.ref.json b/common/tsconfig.ref.json new file mode 100644 index 00000000..a4d1b49f --- /dev/null +++ b/common/tsconfig.ref.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + }, +} \ No newline at end of file diff --git a/common/types/index.ts b/common/types/index.ts new file mode 100644 index 00000000..a0322f82 --- /dev/null +++ b/common/types/index.ts @@ -0,0 +1,7 @@ +export * from "./log"; +export * from "./ppaascommessage"; +export * from "./ppaass3message"; +export * from "./ppaastestmessage"; +export * from "./ppaasteststatus"; +export * from "./s3"; +export * from "./sqs"; diff --git a/common/types/log.ts b/common/types/log.ts new file mode 100644 index 00000000..9a488eed --- /dev/null +++ b/common/types/log.ts @@ -0,0 +1,8 @@ +export enum LogLevel { + TRACE = "TRACE", + DEBUG = "DEBUG", + INFO = "INFO", + WARN = "WARN", + ERROR = "ERROR", + FATAL = "FATAL" +} diff --git a/common/types/ppaascommessage.ts b/common/types/ppaascommessage.ts new file mode 100644 index 00000000..dd97ce5c --- /dev/null +++ b/common/types/ppaascommessage.ts @@ -0,0 +1,17 @@ +export enum MessageType { + StopTest = "StopTest", + KillTest = "KillTest", + TestStatus = "TestStatus", + TestError = "TestError", + TestFailed = "TestFailed", + TestFinished = "TestFinished", + UnitTest = "UnitTest", + UpdateYaml = "UpdateYaml" +} + +// Due to issues with visibility 0 and multiple lockouts, The communications queue is only for talking to the controller +export interface CommunicationsMessage { + testId: string; + messageType: MessageType; + messageData: any; +} diff --git a/common/types/ppaass3message.ts b/common/types/ppaass3message.ts new file mode 100644 index 00000000..efd8e43f --- /dev/null +++ b/common/types/ppaass3message.ts @@ -0,0 +1,3 @@ +export interface UpdateYamlData { + bypassParser?: boolean; +} diff --git a/common/types/ppaastestmessage.ts b/common/types/ppaastestmessage.ts new file mode 100644 index 00000000..f27efef6 --- /dev/null +++ b/common/types/ppaastestmessage.ts @@ -0,0 +1,19 @@ +export type AgentQueueDescription = Record; +export type EnvironmentVariables = Record; + +// Any new channges here must also update the PPaasTestMessage.getTestMessage function +export interface TestMessage { + /** The testId for the new test */ + testId: string; + s3Folder: string; + yamlFile: string; + additionalFiles?: string[]; + testRunTimeMn?: number; + bucketSizeMs?: number; + version: string; + envVariables: EnvironmentVariables; + // Needed for the Test Status + userId?: string; + restartOnFailure: boolean; + bypassParser?: boolean; +} diff --git a/common/types/ppaasteststatus.ts b/common/types/ppaasteststatus.ts new file mode 100644 index 00000000..540a5731 --- /dev/null +++ b/common/types/ppaasteststatus.ts @@ -0,0 +1,25 @@ +export enum TestStatus { + Unknown = "Unknown", + Created = "Created", + Running = "Running", + Failed = "Failed", + Finished = "Finished", + Scheduled = "Scheduled", + Checking = "Checking..." +} + +// Any new fields added here must be added to getTestStatusMessage and readStatus +export interface TestStatusMessage { + instanceId?: string; + hostname?: string; + ipAddress?: string; + startTime: number; + endTime: number; + resultsFilename: string[]; + status: TestStatus; + errors?: string[]; + // These won't be there historically, but new ones should have it + version?: string; + queueName?: string; + userId?: string; +} diff --git a/common/types/s3.ts b/common/types/s3.ts new file mode 100644 index 00000000..480758c6 --- /dev/null +++ b/common/types/s3.ts @@ -0,0 +1,35 @@ +import { PutObjectCommandInput } from "@aws-sdk/client-s3"; +import {Readable} from "stream"; + +export type Body = Buffer|Uint8Array|Blob|string|Readable; + +export interface S3File { + /** + * The object body. Must be a PutObjectCommandInput["Body"] + */ + body?: PutObjectCommandInput["Body"] | Body; + /** + * The s3 key for the object. Will automatically be prepended with the KEYSPACE_PREFIX if we have a shared bucket + */ + key: string; + /** + * Optional: The storage class for the object. Defaults to standard + */ + storageClass?: PutObjectCommandInput["StorageClass"]; + /** + * The Content-Type tag for the object. Defaults to text/plain + */ + contentType: string; + /** + * Optional: The Content-Encoding tag for the object. Defaults to none + */ + contentEncoding?: string; + /** + * Optional: Whether the object is public readable without authorization + */ + publicRead?: boolean; + /** + * Optional: Tags to be applied to the object + */ + tags?: Map; +} diff --git a/common/types/sqs.ts b/common/types/sqs.ts new file mode 100644 index 00000000..5475b075 --- /dev/null +++ b/common/types/sqs.ts @@ -0,0 +1,5 @@ +export enum SqsQueueType { + Test = "Test", + Scale = "Scale", + Communications = "Communications" +} diff --git a/controller/.ebextensions/20pewpewcontroller-security.eb.config b/controller/.ebextensions/20pewpewcontroller-security.eb.config new file mode 100644 index 00000000..8ea8a626 --- /dev/null +++ b/controller/.ebextensions/20pewpewcontroller-security.eb.config @@ -0,0 +1,13 @@ +Resources: + AWSEBSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 8080 + ToPort: 8081 + CidrIp: 10.0.0.0/8 + - IpProtocol: tcp + FromPort: 80 + ToPort: 80 + CidrIp: 10.0.0.0/8 diff --git a/controller/.ebextensions/40change-npm-permissions.config b/controller/.ebextensions/40change-npm-permissions.config new file mode 100644 index 00000000..59f54e4a --- /dev/null +++ b/controller/.ebextensions/40change-npm-permissions.config @@ -0,0 +1,27 @@ +files: + "/opt/elasticbeanstalk/hooks/appdeploy/post/00_set_tmp_permissions.sh": + mode: "000755" + owner: root + group: root + content: | + #!/usr/bin/env bash + echo "set post /tmp permissions start" + sudo chown -R nodejs:nodejs /tmp/.npm + sudo chown -R nodejs:nodejs /tmp/.config + sudo chmod -R 755 /tmp/.config + echo "set /tmp permissions done" + "/opt/elasticbeanstalk/hooks/appdeploy/pre/49_set_tmp_permissions.sh": + mode: "000755" + owner: root + group: root + content: | + #!/usr/bin/env bash + echo "set pre /tmp permissions start" + mkdir -p /tmp/.npm + mkdir -p /tmp/deployment + chmod -R 777 /tmp/.npm + chmod -R 777 /tmp/deployment + rm -r /tmp/deployment/application/node_modules/dtrace-provider/build + ls -al /tmp/.npm/ + pwd + echo "set node_modules permissions done" \ No newline at end of file diff --git a/controller/.ebextensions/nginx/conf.d/client-max-body.conf b/controller/.ebextensions/nginx/conf.d/client-max-body.conf new file mode 100644 index 00000000..a7f5fc3b --- /dev/null +++ b/controller/.ebextensions/nginx/conf.d/client-max-body.conf @@ -0,0 +1 @@ +client_max_body_size 950M; diff --git a/controller/.env.test b/controller/.env.test new file mode 100755 index 00000000..8c4a21d9 --- /dev/null +++ b/controller/.env.test @@ -0,0 +1,22 @@ +# NODE_ENV=test ignores .env and .env.local +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="unit-test-bucket" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://unit-test-bucket.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="unittests/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewcontroller" + +APPLICATION_NAME=pewpewcontroller +AGENT_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/unittests/sqs-communication" + +SECRETS_ENCRYPTION_KEY_NAME="pewpew-test-encrypt-key" +SECRETS_OPENID_CLIENT_SECRET_NAME="pewpew-openid-secret" + +# Not real, but we need somthing +PEWPEW_TEST_ENCRYPT_KEY_OVERRIDE="d49737d3401b5ddb7054e1fb7c4b0164" +PEWPEW_OPENID_SECRET_OVERRIDE="unit-test-123456789012345678901234567890" + +ENV_KEY=".env.test" diff --git a/controller/.github/workflows/pr.yml b/controller/.github/workflows/pr.yml new file mode 100644 index 00000000..a9565a06 --- /dev/null +++ b/controller/.github/workflows/pr.yml @@ -0,0 +1,39 @@ +on: + pull_request: + +name: Pull Request Javascript +jobs: + test-release: + name: Build project + strategy: + matrix: + node-version: [18.x, 20.x] + fail-fast: false + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + persist-credentials: false + + - name: Add Node.js toolchain ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + + - name: Setup Artifactory + env: + CI_USER_TOKEN: ${{ secrets.CI_USER_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + echo -e "machine github.com\n login $CI_USER_TOKEN" > ~/.netrc + echo "//familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/:_authToken=${NPM_TOKEN}" > ~/.npmrc + echo "@fs:registry=https://familysearch.jfrog.io/artifactory/api/npm/fs-npm-prod-virtual/" >> ~/.npmrc + echo git config --global --add url."https://$CI_USER_TOKEN@github.com/".insteadOf "https://github.com/" + + - name: Install NPM Dependencies + run: npm ci + - run: npm run linterror + - run: npm run build:react + - name: Run Tests + run: NODE_ENV=test npm test diff --git a/controller/.gitignore b/controller/.gitignore new file mode 100644 index 00000000..6d7e1aa1 --- /dev/null +++ b/controller/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage +/storybook-static +system-test-encrypt-key* +/.nyc_output +/test/pewpew +/test/password.txt +/pewpew-okta-creds-db +/pewpew-okta-creds-db-np +/pewpew-okta-creds-db.sas +/pewpew-okta-creds-db-np.sas +/secrets.sh + +# next.js +/.next/ +/out/ +/dist/ +/clr/dist/ + +# production +/build + +# misc +.DS_Store +.env +.env.local* +.env.development.local* +.env.test.local* +.env.production.local* +.idea +.vscode/ + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +app-*.json* diff --git a/controller/.platform/hooks/predeploy/200_npm_rebuild.sh b/controller/.platform/hooks/predeploy/200_npm_rebuild.sh new file mode 100755 index 00000000..ed3fd299 --- /dev/null +++ b/controller/.platform/hooks/predeploy/200_npm_rebuild.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -ex + +echo "npm rebuild start" + +APP_STAGING_DIR=$( /opt/elasticbeanstalk/bin/get-config platformconfig -k AppStagingDir ) + +# Add NPM-installed executables to the PATH +NPM_LIB=$( npm list -g | head -1 ) +NPM_HOME=$( dirname "${NPM_LIB}" ) +export PATH="${NPM_HOME}/bin:${PATH}" + +# rebuild to fix the node_modules/.bin/ folder +cd "${APP_STAGING_DIR}" +# https://github.com/vitejs/vite/issues/1361 +# https://github.com/evanw/esbuild/issues/1711 +npm install -D esbuild +npm rebuild +chmod a+x node_modules/.bin/* +npm install -g rimraf + +echo "npm rebuild done" diff --git a/controller/.platform/nginx/conf.d/30_nginx_max_body.conf b/controller/.platform/nginx/conf.d/30_nginx_max_body.conf new file mode 100644 index 00000000..de566453 --- /dev/null +++ b/controller/.platform/nginx/conf.d/30_nginx_max_body.conf @@ -0,0 +1,3 @@ +# https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/platforms-linux-extend.html + +client_max_body_size 950M; diff --git a/controller/.platform/nginx/conf.d/elasticbeanstalk/01_cname_route.conf b/controller/.platform/nginx/conf.d/elasticbeanstalk/01_cname_route.conf new file mode 100644 index 00000000..6275bbb2 --- /dev/null +++ b/controller/.platform/nginx/conf.d/elasticbeanstalk/01_cname_route.conf @@ -0,0 +1,11 @@ +# https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/platforms-linux-extend.html +location /pewpew/load-test/ { + proxy_pass http://127.0.0.1:8081/; + proxy_http_version 1.1; + + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} diff --git a/controller/.sample-env b/controller/.sample-env new file mode 100755 index 00000000..02828a02 --- /dev/null +++ b/controller/.sample-env @@ -0,0 +1,41 @@ +# https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables +# Next.js will load these automatically. We use dotenv-flow to load them for mocha +# Copy this file to .env.local and modify these to your services + +# AWS_PROFILE=default +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_NAME="my-test-service" +PEWPEWCONTROLLER_UNITTESTS_S3_BUCKET_URL="https://my-test-service.s3.amazonaws.com" +PEWPEWCONTROLLER_UNITTESTS_S3_KEYSPACE_PREFIX="pewpewcontroller-unittests-s3/" +PEWPEWCONTROLLER_UNITTESTS_S3_REGION_ENDPOINT="s3-us-east-1.amazonaws.com" +ADDITIONAL_TAGS_ON_ALL="application=pewpewcontroller" + +APPLICATION_NAME=pewpewcontroller +AGENT_ENV="unittests" +AGENT_DESC="c5n.large" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_OUT_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-out" +PEWPEWAGENT_UNITTESTS_SQS_SCALE_IN_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewagent-unittests-sqs-scale-in" +PEWPEWCONTROLLER_UNITTESTS_SQS_COMMUNICATION_QUEUE_URL="https://sqs.us-east-1.amazonaws.com/my-account/pewpewcontroller-unittests-sqs-communication" + +# encryption key name in secrets-manager +SECRETS_ENCRYPTION_KEY_NAME="pewpew-encrypt-key" + +# OpenId client secret name in secrets-manager +SECRETS_OPENID_CLIENT_SECRET_NAME="pewpew-openid-secret" + +# Optional local override for the encryption key value. +# variable name is the encryption key name in caps/underscores with _OVERRIDE at the end +# You can generate one with `openssl rand -hex 16` +# PEWPEW_ENCRYPT_KEY_OVERRIDE="" + +# Optional local override for the client secret value. +# variable name is the client secret name in caps/underscores with _OVERRIDE at the end +# PEWPEW_OPENID_SECRET_OVERRIDE="" + +# OpenId Client Id +OPENID_CLIENT_ID="my-client-id" + +# OpenId Service host +OPENID_HOST="my.login.service.com" +OPENID_PERMISSIONS_READONLY="" +OPENID_PERMISSIONS_USER="" +OPENID_PERMISSIONS_ADMIN="" diff --git a/controller/.storybook/main.ts b/controller/.storybook/main.ts new file mode 100644 index 00000000..9cd41947 --- /dev/null +++ b/controller/.storybook/main.ts @@ -0,0 +1,47 @@ +import type { StorybookConfig } from "@storybook/nextjs"; + +const config: StorybookConfig = { + stories: ["../components/**/story.tsx"], + addons: ["@storybook/addon-actions", "@storybook/addon-links"], + features: { + // legacyMdx1: true, + // storyStoreV7: false, // Opt out of on-demand story loading + // postcss: false, + }, + // In the future consider https://clacified.com/tech-science/16/how-to-set-up-storybook-v6-with-react-17 + typescript: { + check: false, + checkOptions: {}, + reactDocgen: "react-docgen-typescript", + reactDocgenTypescriptOptions: { + shouldExtractLiteralValuesFromEnum: true, + propFilter: prop => prop.parent ? !/node_modules/.test(prop.parent.fileName) : true + } + }, + webpackFinal: (config) => { + if (!config.resolve) { config.resolve = {}; } + config.resolve.fallback = { + ...(config.resolve.fallback || {}), + "fs": false, + "util": false, + "path": false, + "assert": false, + "crypto": false, + }; + if (!config.experiments) { config.experiments = {}; } + config.experiments.asyncWebAssembly = true; + if (!config.output) { config.output = {}; } + // config.output.publicPath = "/"; + config.output.webassemblyModuleFilename = 'static/wasm/[modulehash].wasm'; + return config; + }, + framework: { + name: "@storybook/nextjs", + options: {} + }, + docs: { + autodocs: false + } +}; + +export default config; \ No newline at end of file diff --git a/controller/.storybook/preview.js b/controller/.storybook/preview.js new file mode 100644 index 00000000..0c8caf80 --- /dev/null +++ b/controller/.storybook/preview.js @@ -0,0 +1,18 @@ +// https://www.npmjs.com/package/storybook-addon-next-router +import { RouterContext } from "next/dist/shared/lib/router-context"; // next 12 + +// https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#default-export-in-previewjs +/** @type { import('@storybook/react').Preview } */ +const preview = { + nextRouter: { + Provider: RouterContext.Provider, + }, + // Can't get this to work + // https://storybook.js.org/blog/integrate-nextjs-and-storybook-automatically/ + // nextjs: { + // router: { + // basePath: '/profile', + // }, + // } +}; +export default preview; diff --git a/controller/.storybook/tsconfig.json b/controller/.storybook/tsconfig.json new file mode 100644 index 00000000..0ef833af --- /dev/null +++ b/controller/.storybook/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "outDir": "build/lib", + "module": "commonjs", + "target": "es2022", + "lib": ["es5", "es6", "es7", "es2017", "es2018","dom"], + "sourceMap": true, + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react" + }, + "include": [ + "../types/**/*.ts", + "../components/**/*.ts", + "../components/**/*.tsx" + ] +} \ No newline at end of file diff --git a/controller/README.md b/controller/README.md new file mode 100644 index 00000000..7facb505 --- /dev/null +++ b/controller/README.md @@ -0,0 +1,205 @@ +# ppaas-controller +PewPew as a Service (PPaaS) Controller Machine that runs PewPew Tests using Node.js + Typescript + +## Purpose +This allows us to run load tests using [pewpew](https://github.com/FamilySearch/pewpew) in AWS without having to manually create an ec2 instance. By putting the test files in s3 and putting a message on an SQS queue, an EC2 instance will be spun up to run the test, then shutdown when complete. + +### Shared code +Shared code for the agent and the controller are found in [ppaas-common](https://github.com/FamilySearch/pewpew/common) + +## Environment Config +For your full deployment you should have environment variables injected into CloudFormation to set up the S3 bucket and SQS queues. For local development, copy the `.sample-env` file to `.env.local` (or run `node setup.js`). Then modify the .env.local file to point to your S3 bucket and your SQS queues. You can also override the default AWS profile for your local testing via the `AWS_PROFILE` variable if you are not using `default`. + +## Build +```bash +$ npm i && npm run build +``` + +## Test +```bash +# You must set your aws credentials to start or run tests +# This will automatically get called when you try to commit +$ npm test +``` + +## Acceptance Tests +```bash +# You must set your aws credentials and have run the Run the local server and load the healthcheck `curl http://localhost:3000/api/healthcheck` +$ npm run acceptance +# OR `curl http://localhost:8081/api/healthcheck` +$ PORT=8081 npm run acceptance +``` + +## Setting your secrets-manager overrides for Integration Tests or Running the local server +You also need to configure your Secrets Overrides. You have two options, get the real key from someone who has it, or generate your own key for testing/development but any files stored encrypted in s3 will only be accessible by you. For the OpenId secret, you will need the real one. + +### Generate your own encryption key for testing +1. If you haven't created a `.env.local` run `node setup.js` +2. Uncomment the `PEWPEW_ENCRYPT_KEY_OVERRIDE` from `.env.local` +3. Run `openssl rand -hex 16` and copy the value into the quotes for `PEWPEW_ENCRYPT_KEY_OVERRIDE`. Should be something like `a5158c830ac558b21baddb79803105fb`. + +### Add your own OpenId Secret +1. If you haven't created a `.env.local` run `node setup.js` +2. Uncomment the `SECRETS_OPENID_CLIENT_SECRET_NAME` from `.env.local` +3. Enter the value for your secret into the quotes for `SECRETS_OPENID_CLIENT_SECRET_NAME`. + +## Integration Tests +```bash +# You must set your aws credentials, configure your Secrets overrides, and create a password.txt file as described above +$ npm run integration +``` + +Alternatively you can generate your own key which will work with encrypt/decrypt + +## Run the local server +To start the server, run one of the following commands: + ```bash +# You must set your aws credentials and configure your Secrets overrides as described above + + # debug mode where it watches your changes. http://localhost:3000 + $ npm run dev + + # production start, must be built first with npm run build http://localhost:8081 + $ npm start + + # production start, must be built first with npm run build http://localhost:8082 + $ npm run start2 + + ``` + Use http://localhost:8081/healthcheck/ after running the above command. + +## Run the local server with authentication +Running locally, only dev/integ/okta-np will let you redict back to localhost. Current users set up for dev/integration are ppaasadmin and ppaasuser with the usual test password. okta-np requires "Performance Test Non-Prod" permissions from the tools portal. + +To start the server, run one of the following commands: + ```bash + # You must set your aws credentials to start or run tests + # set AUTH_MODE to any truthy value except the string "false". Example `AUTH_MODE=true` + + # OpenId login. http://localhost:8081 + $ AUTH_MODE=true npm run dev + + # production start, must be built first with npm run build http://localhost:8081 + $ AUTH_MODE=true npm start + + OR + + $ AUTH_MODE=openid npm start + ``` + Use http://localhost:8081/api/healthcheck/ after running the above command. + + ### Testing the redirect for logging in + ```bash + # Run these in separate console after running npm run build + $ ./startauth.sh + ``` + +## npm run commands +```bash +# start server http://localhost:8081 +$ npm start + +# build the TypeScript code (output dir: dist/) +$ npm run build + +# build the Server/Client TypeScript code only (output dir: dist/) +$ npm run build:react + +# build the Test/CLR TypeScript code only (output dir: dist/) +$ npm run build:test + + # storybook mode where it watches your changes. + $ npm run storybook + +# test +$ npm test + +# Run the acceptance tests +$ npm run acceptance +# OR +$ PORT=8081 npm run acceptance + +# Run the integration tests +$ npm run integration + +# Run the unittests and integration tests with code coverage +$ npm run coverage + +# Clean-up the acceptance tests after they're run locally +# You must set your aws credentials +$ npm run testcleanup + +# Clean-up the node_modules because there's a conflict between typescript and @types/react-native +$ npm run fix:install + +# style check TypeScript +$ npm run lint + +# delete the dist dir and node_modules dir +$ npm run clean +``` + +### Testing the routing for a public route as a sub-path +See [ServerFalt](https://serverfault.com/questions/536576/nginx-how-do-i-forward-an-http-request-to-another-port) for basis. In general, after this you'll be able to go to `http://localhost/pewpew/load-test/` and load the site as if you were on another domain as a sub-path. You can use the new `./startrouting.sh` to test out routing. In additional `./startauthrouting.sh` is a combination of `startauth.sh` and `startrouting.sh`. `./devrouting.sh` starts up a dev instance with routing turned on at `http://localhost/pewpew/load-test-dev/` + +```bash +# Install nginx +$ sudo apt-get update +$ sudo apt-get install nginx +$ sudo vi /etc/nginx/sites-available/default +``` + +At the very top of the file (before the `server {}` section) add these lines: +```conf +upstream nodejs { + server 127.0.0.1:8081; + keepalive 256; +} + +upstream nodejs2 { + server 127.0.0.1:8082; + keepalive 256; +} + +upstream nodejs-dev { + server 127.0.0.1:3000; + keepalive 256; +} +``` + +Find the section with `location / {}` and add these sections below it + +```conf +location /pewpew/load-test/ { + proxy_pass http://nodejs/; + proxy_set_header Connection ""; + proxy_http_version 1.1; + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} + +location /status/performance-test2/ { + proxy_pass http://nodejs2/; + proxy_set_header Connection ""; + proxy_http_version 1.1; + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} + +location /pewpew/load-test-dev/ { + proxy_pass http://nodejs-dev/; + proxy_set_header Connection ""; + proxy_http_version 1.1; + proxy_set_header Host $host:$server_port; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} + +##### OPTIONAL ###### +# Change the default port from 80 to 8088 +# Find the lines similar to below but with port 80 and change them to: +listen 8088 default_server; +listen [::]:8088 default_server; +``` diff --git a/controller/acceptance/errorFile.spec.ts b/controller/acceptance/errorFile.spec.ts new file mode 100644 index 00000000..b62b48c6 --- /dev/null +++ b/controller/acceptance/errorFile.spec.ts @@ -0,0 +1,168 @@ +import { API_ERROR, API_SEARCH, API_TEST, TestData } from "../types"; +import { LogLevel, PpaasTestId, TestStatus, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { getPpaasTestId, getTestData, integrationUrl } from "./test.spec"; +import { expect } from "chai"; + +const REDIRECT_TO_S3: boolean = process.env.REDIRECT_TO_S3 === "true"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +describe("ErrorFile API Integration", function () { + let url: string; + let expectedStatus: number = 404; + let yamlFile: string | undefined; + let dateString: string | undefined; + + // We can't use an arrow function here if we want to increase the timeout + // https://stackoverflow.com/questions/41949895/how-to-set-timeout-on-before-hook-in-mocha + before(async function (): Promise { + this.timeout(60000); + url = integrationUrl + API_ERROR; + log("ErrorFile tests url=" + url, LogLevel.DEBUG); + // Initialize to one that will 404 for the build server + const ppaasTestId = await getPpaasTestId(); + yamlFile = ppaasTestId.yamlFile; + dateString = ppaasTestId.dateString; + const sharedTestData: TestData = await getTestData(); + if (sharedTestData.status === TestStatus.Finished) { + expectedStatus = 200; + return; + } + try { + const searchUrl: string = integrationUrl + API_SEARCH + "?s3Folder=&maxResults=100"; + const searchResponse: Response = await fetch(searchUrl); + if (searchResponse.status !== 200) { + throw new Error(`GET ${searchUrl} return status ${searchResponse.status}`); + } + log(`GET ${searchUrl} return status ${searchResponse.status}`, LogLevel.DEBUG, searchResponse.data); + const testDataArray: TestData[] = searchResponse.data; + log("testDataArray", LogLevel.DEBUG, testDataArray); + // Search/remove up to 10 at a time + while (testDataArray.length > 0) { + const searchArray: TestData[] = testDataArray.splice(0, 10); + // The search TestData only has the testId, s3Folder, startTime, and status unknown. We need the full data + try { + const testResponses: Response[] = await Promise.all(searchArray.map((searchData: TestData) => { + const testUrl = integrationUrl + API_TEST + "?testId=" + searchData.testId; + log(`GET testUrl = ${testUrl}`, LogLevel.DEBUG); + return fetch(testUrl); + })); + for (const testResponse of testResponses) { + const testData: TestData = testResponse.data; + log(`GET testUrl = ${testResponse.request?.url}`, LogLevel.DEBUG, { status: testResponse.status, body: testData }); + // Only Finished will actually have the file + if (testResponse.status === 200 && testData.status === TestStatus.Finished) { + log(`foundResponse = ${testData}`, LogLevel.DEBUG, testData); + const foundTestId = PpaasTestId.getFromTestId(testData.testId); + yamlFile = foundTestId.yamlFile; + dateString = foundTestId.dateString; + expectedStatus = 200; + log(`expectedStatus = ${expectedStatus}`, LogLevel.WARN, { yamlFile, dateString, expectedStatus }); + return; + } + } + } catch (error) { + // Swallow and try the next ones + log("Could not Get Tests", LogLevel.ERROR, error); + } + } // End while + log(`expectedStatus = ${expectedStatus}`, LogLevel.WARN, { yamlFile, dateString, expectedStatus }); + } catch (error) { + log("Could not Search and find Results", LogLevel.ERROR, error); + } + }); + + it("GET /error should respond 404 Not Found", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET /error/yamlFile should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined) { done(new Error("No yamlFile")); return; } + fetch(url + `/${yamlFile}`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET error/yamlFile/datestring/ trailing slash should respond 308 redirect", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + const s3Folder = `${yamlFile}/${dateString}`; + fetch(`${url}/${s3Folder}/`).then((res: Response) => { + log(`GET ${url}/${s3Folder}/`, LogLevel.DEBUG, { status: res?.status, headers: res?.headers, res }); + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(308); + expect(res.headers.location, "headers.location").to.not.equal(undefined); + expect(res.headers.location?.endsWith(s3Folder), `${res.headers.location} endsWith ${s3Folder}`).to.equal(true); + done(); + }).catch((error) => done(error)); + }); + + it("GET error/yamlFile/dateString notins3 should respond 404 Not Found", (done: Mocha.Done) => { + const ppaasTestId = PpaasTestId.makeTestId("notins3"); + log("notins3", LogLevel.DEBUG, ppaasTestId); + fetch(url + `/${ppaasTestId.yamlFile}/${ppaasTestId.dateString}`).then((res: Response) => { + log(`GET ${url}/${ppaasTestId.yamlFile}/${ppaasTestId.dateString}`, LogLevel.DEBUG, { status: res?.status, res }); + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET error/yamlFile/datestring ins3 should respond 200", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + log(url + `/${yamlFile}/${dateString}`, LogLevel.WARN); + fetch(url + `/${yamlFile}/${dateString}`).then((res: Response) => { + log(`GET ${url}/${yamlFile}/${dateString}`, LogLevel.DEBUG, { status: res?.status, res }); + expect(res, "res").to.not.equal(undefined); + if (expectedStatus === 200) { + log(`GET ${url}/${yamlFile}/${dateString}`, LogLevel.DEBUG, { status: res?.status, data: res.data }); + if (REDIRECT_TO_S3) { + expect(res.status, "status").to.equal(302); + expect(res.headers.location, "location").to.not.equal(undefined); + expect(typeof res.headers.location, "typeof location").to.equal("string"); + const location = res.headers.location; + log(`GET ${location}`, LogLevel.DEBUG); + fetch(location).then((redirectResponse: Response) => { + log(`GET ${location} response`, LogLevel.DEBUG, { status: redirectResponse?.status, headers: redirectResponse.headers, data: redirectResponse.data }); + expect(redirectResponse.status, "status").to.equal(200); + expect(redirectResponse.data, "body").to.not.equal(undefined); + expect(typeof redirectResponse.data, "typeof redirectResponse.data").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + expect(res.status, "status").to.equal(200); + expect(res.data, "body").to.not.equal(undefined); + expect(typeof res.data, "typeof res.data").to.equal("string"); + done(); + } + } else { + expect(res.status, "status").to.equal(404); + expect(res.data, "body").to.not.equal(undefined); + log("expectedStatus was " + expectedStatus, LogLevel.WARN); + done(); + } + }).catch((error) => done(error)); + }); +}); diff --git a/controller/acceptance/healthcheck.spec.ts b/controller/acceptance/healthcheck.spec.ts new file mode 100644 index 00000000..da610b86 --- /dev/null +++ b/controller/acceptance/healthcheck.spec.ts @@ -0,0 +1,46 @@ +import { API_HEALTHCHECK, API_HEALTHCHECK_HEARTBEAT, API_HEALTHCHECK_S3, API_HEALTHCHECK_SQS } from "../types"; +import { LogLevel, log } from "@fs/ppaas-common"; +import _axios, { AxiosResponse as Response } from "axios"; +import { expect } from "chai"; + +const fetch = _axios.get; + +// Beanstalk __URL +const integrationUrl = "http://" + (process.env.BUILD_APP_URL || `localhost:${process.env.PORT || "8081"}`); + +describe("Healthcheck Integration", () => { + let url: string; + + before(() => { + url = integrationUrl + API_HEALTHCHECK; + log("smoke tests url=" + url, LogLevel.DEBUG); + }); + + it("GET healthcheck should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_HEALTHCHECK).then((res: Response) => { + expect(res.status).to.equal(200); + done(); + }).catch((error) => done(error)); + }); + + it("GET healthcheck/heartbeat should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_HEALTHCHECK_HEARTBEAT).then((res: Response) => { + expect(res.status).to.equal(200); + done(); + }).catch((error) => done(error)); + }); + + it("GET healthcheck/s3 should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_HEALTHCHECK_S3).then((res: Response) => { + expect(res.status).to.equal(200); + done(); + }).catch((error) => done(error)); + }); + + it("GET healthcheck/sqs should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_HEALTHCHECK_SQS).then((res: Response) => { + expect(res.status).to.equal(200); + done(); + }).catch((error) => done(error)); + }); +}); diff --git a/controller/acceptance/pewpew.spec.ts b/controller/acceptance/pewpew.spec.ts new file mode 100644 index 00000000..5b6c9549 --- /dev/null +++ b/controller/acceptance/pewpew.spec.ts @@ -0,0 +1,296 @@ +import { + API_PEWPEW, + FormDataPewPew, + TestManagerError +} from "../types"; +import { LogLevel, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import FormData from "form-data"; +import { createReadStream } from "fs"; +import { expect } from "chai"; +import { latestPewPewVersion } from "../pages/api/util/clientutil"; +import path from "path"; +import semver from "semver"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +// Re-create these here so we don't have to run yamlparser.spec by importing it +const UNIT_TEST_FOLDER = process.env.UNIT_TEST_FOLDER || "test"; +const PEWPEW_FILEPATH = path.join(UNIT_TEST_FOLDER, "pewpew.zip"); + +// Beanstalk __URL +const integrationUrl = "http://" + (process.env.BUILD_APP_URL || `localhost:${process.env.PORT || "8081"}`); + +let sharedPewPewVersions: string[] | undefined; +let uploadedPewPewVersion: string | undefined; + +export async function getPewPewVersions (): Promise { + if (sharedPewPewVersions) { return sharedPewPewVersions; } + await initSharedPewPewVersions(); + return sharedPewPewVersions!; +} + +async function initSharedPewPewVersions (): Promise { + if (sharedPewPewVersions) { return; } + const url: string = integrationUrl + API_PEWPEW; + log("smoke tests url=" + url, LogLevel.DEBUG); + try { + const res = await fetch(url); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body = res.data; + log("/pewpew: " + body, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(Array.isArray(body)).to.equal(true); + expect(body.length).to.be.greaterThan(0); + expect(typeof body[0]).to.equal("string"); + sharedPewPewVersions = body as string[]; + } catch (error) { + log("Could not load queuenames", LogLevel.ERROR, error); + throw error; + } +} + +function convertFormDataPewPewToFormData (formDataPewPew: FormDataPewPew): FormData { + const formData: FormData = new FormData(); + if (formDataPewPew.additionalFiles) { + if (Array.isArray(formDataPewPew.additionalFiles)) { + for (const additionalFile of formDataPewPew.additionalFiles) { + formData.append("additionalFiles", additionalFile.value, additionalFile.options.filename); + } + } else { + formData.append("additionalFiles", formDataPewPew.additionalFiles.value, formDataPewPew.additionalFiles.options); + } + } + if (formDataPewPew.latest) { + formData.append(latestPewPewVersion, formDataPewPew.latest); + } + + return formData; +} + +describe("PewPew API Integration", () => { + let url: string; + + before(() => { + url = integrationUrl + API_PEWPEW; + log("smoke tests url=" + url, LogLevel.DEBUG); + }); + + describe("POST /pewpew", () => { + it("POST /pewpew should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(PEWPEW_FILEPATH); + const formData: FormDataPewPew = { + additionalFiles: { + value: createReadStream(PEWPEW_FILEPATH), + options: { filename } + } + }; + const form = convertFormDataPewPewToFormData(formData); + const headers = form.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data: form, + headers + }).then((res: Response) => { + log("POST /pewpew res", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + expect(res.data).to.not.equal(undefined); + expect(res.data.message).to.not.equal(undefined); + expect(typeof res.data.message).to.equal("string"); + const body: TestManagerError = res.data; + log("body: " + body, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("PewPew uploaded, version"); + expect(body.message).to.not.include("as latest"); + const match: RegExpMatchArray | null = body.message.match(/PewPew uploaded, version: (\d+\.\d+\.\d+)/); + log(`pewpew match: ${match}`, LogLevel.DEBUG, match); + expect(match, "pewpew match").to.not.equal(null); + expect(match!.length, "pewpew match.length").to.be.greaterThan(1); + const version: string = match![1]; + expect(semver.valid(version), `semver.valid(${version})`).to.not.equal(null); + uploadedPewPewVersion = version; + // If this runs before the other acceptance tests populate the shared pewpew versions + if (!sharedPewPewVersions) { + sharedPewPewVersions = [version]; + } else if (!sharedPewPewVersions.includes(version)) { + sharedPewPewVersions.push(version); + } + log("sharedPewPewVersions: " + sharedPewPewVersions, LogLevel.DEBUG); + done(); + }).catch((error) => { + log("POST /pewpew error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /pewpew as latest should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(PEWPEW_FILEPATH); + const formData: FormDataPewPew = { + additionalFiles: [{ + value: createReadStream(PEWPEW_FILEPATH), + options: { filename } + }], + latest: "true" + }; + const form = convertFormDataPewPewToFormData(formData); + const headers = form.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data: form, + headers + }).then((res: Response) => { + log("POST /pewpew res", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + expect(res.data).to.not.equal(undefined); + expect(res.data.message).to.not.equal(undefined); + expect(typeof res.data.message).to.equal("string"); + const body: TestManagerError = res.data; + log("body: " + body, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("PewPew uploaded, version"); + expect(body.message).to.include("as latest"); + const version = latestPewPewVersion; + // If this runs before the other acceptance tests populate the shared pewpew versions + if (!sharedPewPewVersions) { + sharedPewPewVersions = [version]; + } else if (!sharedPewPewVersions.includes(version)) { + sharedPewPewVersions.push(version); + } + log("sharedPewPewVersions: " + sharedPewPewVersions, LogLevel.DEBUG); + done(); + }).catch((error) => { + log("POST /pewpew error", LogLevel.ERROR, error); + done(error); + }); + }); + }); + + describe("GET /pewpew", () => { + before(() => getPewPewVersions()); + + it("GET /pewpew should respond 200 OK", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res.status).to.equal(200); + const pewpewVersions = res.data; + log("pewpewVersions: " + pewpewVersions, LogLevel.DEBUG, pewpewVersions); + expect(pewpewVersions).to.not.equal(undefined); + expect(Array.isArray(pewpewVersions)).to.equal(true); + expect(pewpewVersions.length).to.be.greaterThan(0); + expect(typeof pewpewVersions[0]).to.equal("string"); + sharedPewPewVersions = pewpewVersions; + done(); + }).catch((error) => done(error)); + }); + }); + + describe("DELETE /pewpew", () => { + after(async () => { + // Put the version back + try { + const filename: string = path.basename(PEWPEW_FILEPATH); + const formData: FormDataPewPew = { + additionalFiles: { + value: createReadStream(PEWPEW_FILEPATH), + options: { filename } + } + }; + const form = convertFormDataPewPewToFormData(formData); + const headers = form.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + const res: Response = await fetch(url, { + method: "POST", + data: form, + headers + }); + log("POST /pewpew res", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: TestManagerError = res.data; + log("body", LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(typeof body.message).to.equal("string"); + expect(body.message).to.include("PewPew uploaded, version"); + expect(body.message).to.not.include("as latest"); + const match: RegExpMatchArray | null = body.message.match(/PewPew uploaded, version: (\d+\.\d+\.\d+)/); + log(`pewpew match: ${match}`, LogLevel.DEBUG, match); + expect(match, "pewpew match").to.not.equal(null); + expect(match!.length, "pewpew match.length").to.be.greaterThan(1); + const version: string = match![1]; + expect(semver.valid(version), `semver.valid(${version})`).to.not.equal(null); + uploadedPewPewVersion = version; + // If this runs before the other acceptance tests populate the shared pewpew versions + if (!sharedPewPewVersions) { + sharedPewPewVersions = [version]; + } else if (!sharedPewPewVersions.includes(version)) { + sharedPewPewVersions.push(version); + } + log("sharedPewPewVersions: " + sharedPewPewVersions, LogLevel.DEBUG); + } catch (error) { + throw error; + } + }); + + it("DELETE /pewpew should respond 200 OK", (done: Mocha.Done) => { + expect(uploadedPewPewVersion).to.not.equal(undefined); + const deleteVersion = uploadedPewPewVersion; + const deleteURL = `${url}?version=${deleteVersion}`; + log("DELETE URL", LogLevel.DEBUG, { deleteURL }); + fetch(deleteURL, { method: "DELETE" }).then((res: Response) => { + log("DELETE /pewpew res", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + uploadedPewPewVersion = undefined; + const body: TestManagerError = res.data; + log("body: " + res.data, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(typeof body.message).to.equal("string"); + expect(body.message).to.include("PewPew deleted, version: " + deleteVersion); + done(); + }).catch((error) => { + log("DELETE /pewpew error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("DELETE /pewpew as latest should respond 400 Bad Request", (done: Mocha.Done) => { + const deleteVersion = latestPewPewVersion; + const deleteURL = `${url}?version=${deleteVersion}`; + log("DELETE URL", LogLevel.DEBUG, { deleteURL }); + fetch(deleteURL, { method: "DELETE" }).then((res: Response) => { + log("DELETE /pewpew res", LogLevel.DEBUG, res); + expect(res.status).to.equal(400); + const body: TestManagerError = res.data; + log("body: " + res.data, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(typeof body.message).to.equal("string"); + expect(body.message).to.include(`Pewpew version ${latestPewPewVersion} cannot be deleted`); + done(); + }).catch((error) => { + log("DELETE /pewpew error", LogLevel.ERROR, error); + done(error); + }); + }); + }); + +}); diff --git a/controller/acceptance/queues.spec.ts b/controller/acceptance/queues.spec.ts new file mode 100644 index 00000000..c8fb0e39 --- /dev/null +++ b/controller/acceptance/queues.spec.ts @@ -0,0 +1,89 @@ +import { + API_QUEUES, + API_QUEUE_NAMES +} from "../types"; +import { AgentQueueDescription, LogLevel, log } from "@fs/ppaas-common"; +import _axios, { AxiosResponse as Response } from "axios"; +import { expect } from "chai"; + +const fetch = _axios.get; + +// Beanstalk __URL +const integrationUrl = "http://" + (process.env.BUILD_APP_URL || `localhost:${process.env.PORT || "8081"}`); + +let sharedQueueNames: string[] | undefined; + +export async function getQueueNames (): Promise { + if (sharedQueueNames) { return sharedQueueNames; } + await initSharedQueueNames(); + return sharedQueueNames!; +} + +async function initSharedQueueNames (): Promise { + if (sharedQueueNames) { return; } + const url: string = integrationUrl + API_QUEUE_NAMES; + log("smoke tests url=" + url, LogLevel.DEBUG); + try { + const res: Response = await fetch(url); + const body = res.data; + expect(res.status, JSON.stringify(body)).to.equal(200); + log("queuenames: " + body, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.queueNames).to.not.equal(undefined); + expect(Array.isArray(body.queueNames)).to.equal(true); + expect(body.queueNames.length).to.be.greaterThan(0); + expect(typeof body.queueNames[0]).to.equal("string"); + sharedQueueNames = body.queueNames as string[]; + } catch (error) { + log("Could not load queuenames", LogLevel.ERROR, error); + throw error; + } +} + +describe("Queues API Integration", () => { + let url: string; + + before(() => { + url = integrationUrl + API_QUEUE_NAMES; + log("smoke tests url=" + url, LogLevel.DEBUG); + return getQueueNames(); + }); + + describe("GET /queuenames", () => { + it("GET /queuenames should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_QUEUE_NAMES).then((res: Response) => { + const body = res.data; + expect(res.status, JSON.stringify(body)).to.equal(200); + log("queuenames: " + body, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.queueNames).to.not.equal(undefined); + expect(Array.isArray(body.queueNames)).to.equal(true); + expect(body.queueNames.length).to.be.greaterThan(0); + sharedQueueNames = body.queueNames; + done(); + }).catch((error) => done(error)); + }); + }); + + describe("GET /queues", () => { + it("GET /queues should respond 200 OK", (done: Mocha.Done) => { + fetch(integrationUrl + API_QUEUES).then((res: Response) => { + expect(res.status).to.equal(200); + const queues = res.data; + log("queues: " + queues, LogLevel.DEBUG, queues); + expect(queues, "queues").to.not.equal(undefined); + const entries = Object.entries(queues); + expect(entries.length, "entries.length").to.be.greaterThan(0); + if (sharedQueueNames && sharedQueueNames.length > 0) { + expect(Object.keys(queues), "keys").to.include(sharedQueueNames[0]); + } + for (const [queueName, queueValue] of entries) { + expect(typeof queueValue, `typeof queues[${queueName}]`).to.equal("string"); + } + const queuesType: AgentQueueDescription = queues; + log("queuesType", LogLevel.DEBUG, queuesType); + done(); + }).catch((error) => done(error)); + }); + }); +}); diff --git a/controller/acceptance/resultsFile.spec.ts b/controller/acceptance/resultsFile.spec.ts new file mode 100644 index 00000000..ea5df0f1 --- /dev/null +++ b/controller/acceptance/resultsFile.spec.ts @@ -0,0 +1,211 @@ +import { API_JSON, API_SEARCH, API_TEST, TestData } from "../types"; +import { LogLevel, PpaasTestId, TestStatus, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { getPpaasTestId, getTestData, integrationUrl } from "./test.spec"; +import { expect } from "chai"; + +const REDIRECT_TO_S3: boolean = process.env.REDIRECT_TO_S3 === "true"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +describe("ResultsFile API Integration", function () { + let url: string; + let yamlFile: string | undefined; + let dateString: string | undefined; + let resultsFile: string | undefined; + + // We can't use an arrow function here if we want to increase the timeout + // https://stackoverflow.com/questions/41949895/how-to-set-timeout-on-before-hook-in-mocha + before(async function (): Promise { + this.timeout(60000); + url = integrationUrl + API_JSON; + log("ResultsFile tests url=" + url, LogLevel.DEBUG); + const ppaasTestId = await getPpaasTestId(); + yamlFile = ppaasTestId.yamlFile; + dateString = ppaasTestId.dateString; + const sharedTestData: TestData = await getTestData(); + if (sharedTestData.resultsFileLocation && sharedTestData.resultsFileLocation.length > 0) { + // Initialize to one that will 302 for the build server + resultsFile = sharedTestData.resultsFileLocation[0].split("/").pop(); + } + try { + const searchUrl: string = integrationUrl + API_SEARCH + "?s3Folder=&maxResults=100"; + const searchResponse: Response = await fetch(searchUrl); + if (searchResponse.status !== 200) { + throw new Error(`GET ${searchUrl} return status ${searchResponse.status}`); + } + log(`GET ${searchUrl} return status ${searchResponse.status}`, LogLevel.DEBUG, searchResponse.data); + expect(Array.isArray(searchResponse.data)).to.equal(true); + if (searchResponse.data.length > 0) { + log("searchResponse.data[0]", LogLevel.DEBUG, searchResponse.data[0]); + expect(typeof searchResponse.data[0]).to.equal("object"); + expect(typeof searchResponse.data[0].testId, JSON.stringify(searchResponse.data[0].testId)).to.equal("string"); + expect(typeof searchResponse.data[0].s3Folder, JSON.stringify(searchResponse.data[0].s3Folder)).to.equal("string"); + } + const testDataArray: TestData[] = searchResponse.data; + log("testDataArray", LogLevel.DEBUG, testDataArray); + // Search/remove up to 10 at a time + while (testDataArray.length > 0) { + const searchArray: TestData[] = testDataArray.splice(0, 10); + // The search TestData only has the testId, s3Folder, startTime, and status unknown. We need the full data + try { + const testResponses: Response[] = await Promise.all(searchArray.map((searchData: TestData) => { + const testUrl = integrationUrl + API_TEST + "?testId=" + searchData.testId; + log(`GET testUrl = ${testUrl}`, LogLevel.DEBUG); + return fetch(testUrl); + })); + for (const testResponse of testResponses) { + expect(testResponse.data, "testResponse.data").to.not.equal(undefined); + expect(typeof (testResponse.data as TestData).testId, "testId: " + JSON.stringify(testResponse.data)).to.equal("string"); + expect(typeof (testResponse.data as TestData).s3Folder, "s3Folder: " + JSON.stringify(testResponse.data)).to.equal("string"); + const testData: TestData = testResponse.data; + log(`GET testUrl = ${testResponse.request?.url}`, LogLevel.DEBUG, { status: testResponse.status, body: testData }); + // Only Finished will actually have the file + if (testResponse.status === 200 && testData.status === TestStatus.Finished + && testData.resultsFileLocation && testData.resultsFileLocation.length > 0) { + log(`foundResponse = ${testData}`, LogLevel.DEBUG, testData); + const foundTestId = PpaasTestId.getFromTestId(testData.testId); + yamlFile = foundTestId.yamlFile; + dateString = foundTestId.dateString; + resultsFile = testData.resultsFileLocation[0].split("/").pop(); + log(`resultsFile = ${resultsFile}`, LogLevel.WARN, { yamlFile, dateString, resultsFile }); + return; + } + } + } catch (error) { + // Swallow and try the next ones + log("Could not Get Tests", LogLevel.ERROR, error); + } + } // End while + log(`resultsFile = ${resultsFile}`, LogLevel.WARN, { yamlFile, dateString, resultsFile }); + } catch (error) { + log("Could not Search and find Results", LogLevel.ERROR, error); + } +}); + + it("GET json should respond 404 Not Found", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET json/yamlFile should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined) { done(new Error("No yamlFile")); return; } + fetch(url + `/${yamlFile}`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET json/yamlFile/dateString should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET json/yamlFile/dateString/notjson should respond 400 Bad Request", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}/${yamlFile}.yaml`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET json/yamlFile/dateString/notins3.json should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}/stats-notins3.json`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + const validateJson = (data: string) => { + expect(data, "data").to.include("{\"test\":\""); + expect(data, "res.data").to.include("\"bin\":\""); + for (const line of (data as string).replace(/}{/g, "}\n{").split("\n")) { + let json: any; + try { + json = JSON.parse(line); + } catch (error) { + const errorString = "Each line should JSON parsable: "; + log(errorString, LogLevel.ERROR, error, { line }); + throw new Error(errorString + error); + } + expect(json, `json from [${line}]`).to.not.equal(undefined); + expect(json, `json from [${line}]`).to.not.equal(null); + if ("test" in json) { + expect(typeof json.test, "typeof json.test").to.equal("string"); + expect(typeof json.bin, "typeof json.bin").to.equal("string"); + expect(typeof json.bucketSize, "typeof json.bucketSize").to.equal("number"); + } else if ("index" in json) { + expect(typeof json.index, "typeof json.index").to.equal("number"); + expect(typeof json.tags, "typeof json.tags").to.equal("object"); + expect(typeof json.tags?._id, "typeof json.tags._id").to.equal("string"); + expect(typeof json.tags?.url, "typeof json.tags.url").to.equal("string"); + } else { + expect("entries" in json, "\"entries\" in json").to.equal(true); + } + } + }; + + it("GET json/yamlFile/datestring/stats-ins3.json should respond 200", (done: Mocha.Done) => { + if (resultsFile === undefined) { done(new Error("No resultsFile")); return; } + log(url + `/${yamlFile}/${dateString}/${resultsFile}`, LogLevel.WARN); + fetch(url + `/${yamlFile}/${dateString}/${resultsFile}`).then((res: Response) => { + log(`GET ${url}/${yamlFile}/${dateString}/${resultsFile}`, LogLevel.DEBUG, { res }); + expect(res, "res").to.not.equal(undefined); + // The build server will 404 because there won't be any completed tests, localhost should have some + if (url.includes("localhost")) { + log(`GET ${url}/${yamlFile}/${dateString}/${resultsFile}`, LogLevel.DEBUG, { data: res.data }); + if (REDIRECT_TO_S3) { + expect(res.status, "status").to.equal(302); + expect(res.headers.location, "location").to.not.equal(undefined); + expect(typeof res.headers.location, "typeof location").to.equal("string"); + const location = res.headers.location; + log(`GET ${location}`, LogLevel.DEBUG); + fetch(location).then((redirectResponse: Response) => { + log(`GET ${location} response`, LogLevel.DEBUG, { status: redirectResponse?.status, headers: redirectResponse.headers, data: redirectResponse.data }); + expect(redirectResponse.status, "status").to.equal(200); + expect(redirectResponse.data, "body").to.not.equal(undefined); + expect(typeof redirectResponse.data, "typeof redirectResponse.data").to.equal("string"); + validateJson(res.data as string); + done(); + }).catch((error) => done(error)); + } else { + expect(res.status, "status").to.equal(200); + expect(res.data, "data").to.not.equal(undefined); + expect(typeof res.data, "typeof res.data").to.equal("string"); + validateJson(res.data as string); + done(); + } + } else { + expect(res.status, "status").to.equal(404); + done(); + } + }).catch((error) => done(error)); + }); +}); diff --git a/controller/acceptance/schedule.spec.ts b/controller/acceptance/schedule.spec.ts new file mode 100644 index 00000000..e32c1375 --- /dev/null +++ b/controller/acceptance/schedule.spec.ts @@ -0,0 +1,220 @@ +import { + API_SCHEDULE, + FormDataPost, + TestData, + TestManagerError +} from "../types"; +import { BASIC_FILEPATH, getScheduledTestData, integrationUrl, unsetScheduledTestData } from "./test.spec"; +import { LogLevel, TestStatus, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { EventInput } from "@fullcalendar/core"; +import { expect } from "chai"; +import { getQueueNames } from "./queues.spec"; +import path from "path"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +describe("Schedule API Integration", () => { + let url: string; + let scheduledTestData: TestData | undefined; + let recurringTestData: TestData | undefined; + let deletedTestData: TestData | undefined; + + before(() => { + url = integrationUrl + API_SCHEDULE; + log("smoke tests url=" + url, LogLevel.DEBUG); + }); + + after(async () => { + try { + expect(scheduledTestData, "scheduledTestData").to.not.equal(undefined); + expect(recurringTestData, "recurringTestData").to.not.equal(undefined); + expect(deletedTestData, "deletedTestData").to.not.equal(undefined); + const res = await fetch(url); + log("GET /schedule response", LogLevel.DEBUG, res); + // const bodyText = await res.text(); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + log("schedule: " + res.data, LogLevel.DEBUG, res.data); + expect(res.data, "data").to.not.equal(undefined); + const schedule: EventInput[] = res.data; + expect(schedule, "schedule").to.not.equal(undefined); + expect(Array.isArray(schedule), "isArray(schedule)").to.equal(true); + expect(schedule.length).to.be.greaterThan(0); + const scheduledEvent: EventInput | undefined = schedule.find((value: EventInput) => value.id === scheduledTestData!.testId); + const recurringEvent: EventInput | undefined = schedule.find((value: EventInput) => value.id === recurringTestData!.testId); + const deletedEvent: EventInput | undefined = schedule.find((value: EventInput) => value.id === deletedTestData!.testId); + expect(scheduledEvent, "scheduledEvent").to.not.equal(undefined); + expect(recurringEvent, "recurringEvent").to.not.equal(undefined); + expect(deletedEvent, "deletedEvent").to.equal(undefined); + expect(scheduledEvent!.start, "scheduledEvent!.start").to.equal(scheduledTestData!.startTime); + expect(recurringEvent!.startRecur, "recurringEvent!.startRecur").to.equal(recurringTestData!.startTime); + } catch (error) { + log("Schedule API Integration after error", LogLevel.ERROR, error); + throw error; + } + }); + + describe("GET /schedule", () => { + before(async () => scheduledTestData = await getScheduledTestData()); + + it("GET /schedule should respond 200 OK", (done: Mocha.Done) => { + expect(scheduledTestData).to.not.equal(undefined); + fetch(url).then((res) => { + log("GET /schedule response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + const body = res.data; + log("schedule: " + body, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + const schedule: EventInput[] = body; + expect(schedule).to.not.equal(undefined); + expect(Array.isArray(schedule)).to.equal(true); + expect(schedule.length).to.be.greaterThan(0); + expect(schedule.some((value: EventInput) => value.id === scheduledTestData!.testId)).to.equal(true); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("PUT /schedule", () => { + let queueName: string; + before(async () => { + try { + const queueNames: string[] = await getQueueNames(); + expect(queueNames).to.not.equal(undefined); + expect(queueNames.length).to.be.greaterThan(0); + queueName = queueNames[0]; + scheduledTestData = await getScheduledTestData(); + unsetScheduledTestData(); + recurringTestData = await getScheduledTestData(); + unsetScheduledTestData(); + } catch (error) { + log("PUT /schedule before error", LogLevel.ERROR, error); + throw error; + } + }); + + it("PUT /schedule new date should respond 200 OK", (done: Mocha.Done) => { + expect(scheduledTestData, "scheduledTestData").to.not.equal(undefined); + const scheduleDate: number = Date.now() + 600000; + const formData: FormDataPost = { + yamlFile: path.basename(BASIC_FILEPATH), + testId: scheduledTestData!.testId, + queueName, + scheduleDate + }; + log("formData schedule new date", LogLevel.DEBUG, formData); + fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + data: formData + }).then((res: Response) => { + log("PUT /schedule new date response", LogLevel.DEBUG, res); + const body = res.data; + expect(res.status, JSON.stringify(body)).to.equal(200); + log("schedule: " + body, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + const testData: TestData = body; + expect(testData, "testData").to.not.equal(undefined); + expect(testData.testId, "testId").to.equal(scheduledTestData!.testId); + expect(testData.s3Folder, "s3Folder").to.equal(scheduledTestData!.s3Folder); + expect(testData.status, "status").to.equal(TestStatus.Scheduled); + expect(testData.startTime, "startTime").to.equal(scheduleDate); + scheduledTestData = testData; + done(); + }).catch((error) => done(error)); + }); + + it("PUT /schedule recurring should respond 200 OK", (done: Mocha.Done) => { + expect(recurringTestData, "recurringTestData").to.not.equal(undefined); + const scheduleDate: number = Date.now() + 600000; + const endDate: number = scheduleDate + (7 * 24 * 60 * 60000); + const formData: FormDataPost = { + yamlFile: path.basename(BASIC_FILEPATH), + testId: recurringTestData!.testId, + queueName, + scheduleDate, + endDate, + daysOfWeek: JSON.stringify([1,3,5]) + }; + log("formData schedule recurring", LogLevel.DEBUG, formData); + fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json" + }, + data: formData + }).then((res: Response) => { + log("PUT /schedule recurring response", LogLevel.DEBUG, res); + const body = res.data; + expect(res.status, JSON.stringify(body)).to.equal(200); + log("schedule: " + body, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + const testData: TestData = body; + expect(testData, "testData").to.not.equal(undefined); + expect(testData.testId, "testId").to.equal(recurringTestData!.testId); + expect(testData.s3Folder, "s3Folder").to.equal(recurringTestData!.s3Folder); + expect(testData.status, "status").to.equal(TestStatus.Scheduled); + expect(testData.startTime, "startTime").to.equal(scheduleDate); + recurringTestData = testData; + done(); + }).catch((error) => done(error)); + }); + }); + + describe("DELETE /schedule", () => { + before(async () => { + deletedTestData = await getScheduledTestData(); + unsetScheduledTestData(); + }); + + it("DELETE /schedule?testId=validTestId should respond 200 OK", (done: Mocha.Done) => { + expect(deletedTestData).to.not.equal(undefined); + const scheduledTestId: string = deletedTestData!.testId; + log("scheduled testId: " + scheduledTestId, LogLevel.DEBUG, scheduledTestId); + fetch(url + "?testId=" + scheduledTestId, { method: "DELETE" }).then((res) => { + log("DELETE /schedule response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + const body = res.data; + log("delete: " + body, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + const result: TestManagerError = body; + expect(result.message, "result.message").to.not.equal(undefined); + expect(typeof result.message, "typeof result.message").to.equal("string"); + expect(result.message, "result.message").to.include(scheduledTestId); + done(); + }).catch((error) => done(error)); + }); + + it("DELETE /schedule?testId=invalid should respond 404 Not Found", (done: Mocha.Done) => { + fetch(url + "?testId=invalid", { method: "DELETE" }).then((res) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("DELETE /schedule no testId should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url, { method: "DELETE" }).then((res) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + }); +}); diff --git a/controller/acceptance/search.spec.ts b/controller/acceptance/search.spec.ts new file mode 100644 index 00000000..d9c1891d --- /dev/null +++ b/controller/acceptance/search.spec.ts @@ -0,0 +1,287 @@ +import { API_SEARCH, API_TEST_STATUS, TestData, TestManagerMessage } from "../types"; +import { LogLevel, PpaasTestId, TestStatus, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { getTestData, integrationUrl } from "./test.spec"; +import { expect } from "chai"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +let sharedSearchResults: TestData[] | undefined; + +async function getSearchResults (): Promise { + if (sharedSearchResults) { return sharedSearchResults; } + await initSharedSearchResults(); + return sharedSearchResults!; +} + +async function initSharedSearchResults (): Promise { + if (!sharedSearchResults) { + const url: string = integrationUrl + API_SEARCH; + log("initSharedSearchResults url=" + url, LogLevel.DEBUG); + try { + const response: Response = await fetch(`${url}?s3Folder=&maxResults=100`); + log("GET /search res", LogLevel.DEBUG, response); + const body: unknown = response.data; + log("body: " + body, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(Array.isArray(body), "isArray").to.equal(true); + expect((body as unknown[]).length, "body.length").to.be.greaterThan(0); + const testElement: unknown = (body as unknown[])[0]; + expect("testId" in (testElement as TestData), "testId in element").to.equal(true); + expect("s3Folder" in (testElement as TestData), "s3Folder in element").to.equal(true); + expect("status" in (testElement as TestData), "status in element").to.equal(true); + // Search a few of them and see if any are finished + sharedSearchResults = body as TestData[]; + } catch (error) { + log("GET /search error", LogLevel.ERROR, error); + throw error; + } + } +} + +describe("Search API Integration", () => { + let s3Folder: string | undefined; + let url: string; + + before(async () => { + url = integrationUrl + API_SEARCH; + log("search tests url=" + url, LogLevel.DEBUG); + const testData = await getTestData(); + s3Folder = testData.s3Folder; + }); + + describe("GET /search", () => { + it("GET /search should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /search?s3Folder=NotInS3 should respond 204 No Content", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").s3Folder; + log("validButNotInS3 s3Folder: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?s3Folder=" + validButNotInS3).then((res: Response) => { + expect(res.status).to.equal(204); + done(); + }).catch((error) => done(error)); + }); + + it("GET /search?s3Folder=. character should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url + "?s3Folder=.").then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /search?s3Folder=validInS3 should respond 200 OK", (done: Mocha.Done) => { + if (s3Folder) { + log("validInS3 s3Folder: " + s3Folder, LogLevel.DEBUG, s3Folder); + fetch(url + "?s3Folder=" + s3Folder).then((res: Response) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(Array.isArray(body), "isArray").to.equal(true); + const results = body as unknown[]; + expect(results.length).to.be.greaterThan(0); + const result: unknown = results[0]; + expect(typeof result).to.equal("object"); + expect(typeof (result as TestData).testId, "typeof testId").to.equal("string"); + expect(typeof (result as TestData).s3Folder, "typeof s3Folder").to.equal("string"); + expect(typeof (result as TestData).status, "typeof status").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No s3Folder")); + } + }); + + it("GET /search?s3Folder= empty should respond 200 OK", (done: Mocha.Done) => { + log("validInS3 s3Folder: ", LogLevel.DEBUG, s3Folder); + fetch(url + "?s3Folder=").then((res: Response) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(Array.isArray(body), "isArray").to.equal(true); + const results = body as unknown[]; + expect(results.length).to.be.greaterThan(0); + const result: unknown = results[0]; + expect(typeof result).to.equal("object"); + expect(typeof (result as TestData).testId, "typeof testId").to.equal("string"); + expect(typeof (result as TestData).s3Folder, "typeof s3Folder").to.equal("string"); + expect(typeof (result as TestData).status, "typeof status").to.equal("string"); + done(); + }).catch((error) => done(error)); + }); + + it("GET /search?s3Folder=validInS3&s3Folder=validInS3 should respond 400 BadRequest", (done: Mocha.Done) => { + if (s3Folder) { + log("validInS3 s3Folder: " + s3Folder, LogLevel.DEBUG, s3Folder); + fetch(url + "?s3Folder=" + s3Folder + "&s3Folder=" + s3Folder).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No s3Folder")); + } + }); + }); + + describe("PUT /search", () => { + it("PUT /search should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("PUT /search?s3Folder=NotInS3 should respond 204 No Content", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").s3Folder; + log("validButNotInS3 s3Folder: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?s3Folder=" + validButNotInS3, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(204); + done(); + }).catch((error) => done(error)); + }); + + it("PUT /search?s3Folder=validInS3 should respond 200 OK", (done: Mocha.Done) => { + if (s3Folder) { + log("validInS3 s3Folder: " + s3Folder, LogLevel.DEBUG, s3Folder); + fetch(url + "?s3Folder=" + s3Folder, { method: "PUT" }).then((res: Response) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(Array.isArray(body), "isArray").to.equal(true); + const results = body as unknown[]; + expect(results.length).to.be.greaterThan(0); + const result: unknown = results[0]; + expect(typeof result).to.equal("object"); + expect(typeof (result as TestData).testId, "typeof testId").to.equal("string"); + expect(typeof (result as TestData).s3Folder, "typeof s3Folder").to.equal("string"); + expect(typeof (result as TestData).status, "typeof status").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No s3Folder")); + } + }); + + it("PUT /search?s3Folder=validInS3&s3Folder=validInS3 should respond 400 BadRequest", (done: Mocha.Done) => { + if (s3Folder) { + log("validInS3 s3Folder: " + s3Folder, LogLevel.DEBUG, s3Folder); + fetch(url + "?s3Folder=" + s3Folder + "&s3Folder=" + s3Folder, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No s3Folder")); + } + }); + }); +}); + +describe("TestStatus API Integration", () => { + let createdTestData: TestData | undefined; + let searchedTestData: TestData | undefined; + let url: string; + + before(async () => { + url = integrationUrl + API_TEST_STATUS; + log("teststatus tests url=" + url, LogLevel.DEBUG); + createdTestData = await getTestData(); + const searchResults = await getSearchResults(); + expect(searchResults.length).to.be.greaterThan(0); + searchedTestData = searchResults[0]; + }); + + it("GET /teststatus should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /teststatus?testId=NotInS3 should respond 404 Not Found", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").testId; + log("validButNotInS3 testId: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?testId=" + validButNotInS3).then((res: Response) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET /teststatus?testId=createdInS3 should respond 200 OK", (done: Mocha.Done) => { + if (createdTestData) { + log("createdInS3 testId: " + createdTestData.testId, LogLevel.DEBUG, createdTestData); + fetch(url + "?testId=" + createdTestData.testId).then((res: Response) => { + log ("createdInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + const status: string = (body as TestManagerMessage).message; + expect((Object.values(TestStatus) as string[]).includes(status), "status is TestStatus").to.equal(true); + expect(status, "status").to.equal(createdTestData!.status); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("GET /teststatus?testId=foundInS3 should respond 200 OK", (done: Mocha.Done) => { + if (searchedTestData) { + log("foundInS3 testId: " + searchedTestData.testId, LogLevel.DEBUG, searchedTestData); + fetch(url + "?testId=" + searchedTestData.testId).then((res: Response) => { + log ("foundInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + const status: string = (body as TestManagerMessage).message; + expect((Object.values(TestStatus) as string[]).includes(status), "status is TestStatus").to.equal(true); + expect(status, "status").to.not.equal(TestStatus.Unknown); + if (searchedTestData && searchedTestData.status !== TestStatus.Unknown) { + expect(status, "status").to.equal(searchedTestData!.status); + } + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("GET /teststatus?testId=validInS3&testId=validInS3 should respond 400 BadRequest", (done: Mocha.Done) => { + if (createdTestData) { + const createdTestId = createdTestData.testId; + log("validInS3 testId: " + createdTestId, LogLevel.DEBUG, createdTestId); + fetch(url + "?testId=" + createdTestId + "&testId=" + createdTestId).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); +}); diff --git a/controller/acceptance/stop.spec.ts b/controller/acceptance/stop.spec.ts new file mode 100644 index 00000000..3ae737d5 --- /dev/null +++ b/controller/acceptance/stop.spec.ts @@ -0,0 +1,187 @@ +import { API_STOP, TestManagerMessage } from "../types"; +import { LogLevel, PpaasTestId, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { getPpaasTestId, integrationUrl } from "./test.spec"; +import { expect } from "chai"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +describe("Stop API Integration", () => { + let testId: string | undefined; + let url: string; + + before(async () => { + url = integrationUrl + API_STOP; + log("smoke tests url=" + url, LogLevel.DEBUG); + const ppaasTestId = await getPpaasTestId(); + testId = ppaasTestId.testId; + }); + + describe("GET /stop", () => { + it("GET /stop should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /stop?testId=invalid should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url + "?testId=invalid").then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /stop?testId=validButNotInS3 should respond 404 Not Found", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").testId; + log("validButNotInS3 testId: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?testId=" + validButNotInS3).then((res: Response) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET /stop?testId=validInS3 should respond 200 OK", (done: Mocha.Done) => { + if (testId) { + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?testId=" + testId).then((res: Response) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + expect((body as TestManagerMessage).message, "message").to.include("Stop TestId " + testId); + expect(typeof (body as TestManagerMessage).messageId, "typeof messageId").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("GET /stop?testId=validInS3&kill=true should respond 200 OK", (done: Mocha.Done) => { + if (testId) { + log("validInS3 kill testId: " + testId, LogLevel.DEBUG, testId); + fetch(`${url}?testId=${testId}&kill=true`).then((res: Response) => { + log ("validInS3 kill response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + expect((body as TestManagerMessage).message, "message").to.include("Kill TestId " + testId); + expect(typeof (body as TestManagerMessage).messageId, "typeof messageId").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("GET /stop?testId=validInS3&testId=validInS3 should respond 400 BadRequest", (done: Mocha.Done) => { + if (testId) { + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?testId=" + testId + "&testId=" + testId).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + }); + + describe("PUT /stop", () => { + it("PUT /stop should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("PUT /stop?testId=invalid should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url + "?testId=invalid", { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("PUT /stop?testId=validButNotInS3 should respond 404 Not Found", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").testId; + log("validButNotInS3 testId: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?testId=" + validButNotInS3, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("PUT /stop?testId=validInS3 should respond 200 OK", (done: Mocha.Done) => { + if (testId) { + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?testId=" + testId, { method: "PUT" }).then((res: Response) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + expect((body as TestManagerMessage).message, "message").to.include("Stop TestId " + testId); + expect(typeof (body as TestManagerMessage).messageId, "typeof messageId").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("PUT /stop?testId=validInS3&kill=true should respond 200 OK", (done: Mocha.Done) => { + if (testId) { + log("validInS3 kill testId: " + testId, LogLevel.DEBUG, testId); + fetch(`${url}?testId=${testId}&kill=true`, { method: "PUT" }).then((res: Response) => { + log ("validInS3 kill response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(body).to.not.equal(undefined); + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as TestManagerMessage).message, "typeof message").to.equal("string"); + expect((body as TestManagerMessage).message, "message").to.include("Kill TestId " + testId); + expect(typeof (body as TestManagerMessage).messageId, "typeof messageId").to.equal("string"); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + + it("PUT /stop?testId=validInS3&testId=validInS3 should respond 400 BadRequest", (done: Mocha.Done) => { + if (testId) { + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?testId=" + testId + "&testId=" + testId, { method: "PUT" }).then((res: Response) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + } else { + done(new Error("No testId")); + } + }); + }); +}); diff --git a/controller/acceptance/test.spec.ts b/controller/acceptance/test.spec.ts new file mode 100644 index 00000000..dcbaf5ed --- /dev/null +++ b/controller/acceptance/test.spec.ts @@ -0,0 +1,1775 @@ +import { + API_TEST, + AllTests, + EnvironmentVariablesFile, + FileData, + FormDataPost, + FormDataPut, + PreviousTestData, + TestData, + TestManagerError +} from "../types"; +import { LogLevel, PpaasTestId, TestStatus, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import FormData from "form-data"; +import { createReadStream } from "fs"; +import { expect } from "chai"; +import { getPewPewVersions } from "./pewpew.spec"; +import { getQueueNames } from "./queues.spec"; +import { latestPewPewVersion } from "../pages/api/util/clientutil"; +import path from "path"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +// Re-create these here so we don't have to run yamlparser.spec by importing it +const UNIT_TEST_FOLDER = process.env.UNIT_TEST_FOLDER || "test"; +export const BASIC_FILEPATH = path.join(UNIT_TEST_FOLDER, "basic.yaml"); +const BASIC_FILEPATH_WITH_ENV = path.join(UNIT_TEST_FOLDER, "basicwithenv.yaml"); +const BASIC_FILEPATH_WITH_FILES = path.join(UNIT_TEST_FOLDER, "basicwithfiles.yaml"); +const BASIC_FILEPATH_NO_PEAK_LOAD = path.join(UNIT_TEST_FOLDER, "basicnopeakload.yaml"); +const BASIC_FILEPATH_HEADERS_ALL = path.join(UNIT_TEST_FOLDER, "basicheadersall.yaml"); +const NOT_YAML_FILEPATH = path.join(UNIT_TEST_FOLDER, "text.txt"); +const NOT_YAML_FILEPATH2 = path.join(UNIT_TEST_FOLDER, "text2.txt"); +const ZIP_TEST_DIR_PATH: string = path.join(UNIT_TEST_FOLDER, "testdir.zip"); +const ZIP_TEST_FILES_PATH: string = path.join(UNIT_TEST_FOLDER, "testfiles.zip"); +const ZIP_TEST_FILES_11_PATH: string = path.join(UNIT_TEST_FOLDER, "testfiles11.zip"); +const ZIP_TEST_INVALID_PATH: string = path.join(UNIT_TEST_FOLDER, "testinvalid.zip"); +const ZIP_TEST_YAML_PATH: string = path.join(UNIT_TEST_FOLDER, "testyaml.zip"); +const ZIP_TEST_YAML_ENV_PATH: string = path.join(UNIT_TEST_FOLDER, "testyamlenv.zip"); +const ZIP_TEST_YAMLS_PATH: string = path.join(UNIT_TEST_FOLDER, "testyamls.zip"); +/** Environment variables that will be posted from the client on re-run */ +const defaultEnvironmentVariablesFromPrior: EnvironmentVariablesFile = { + SERVICE_URL_AGENT: { value: "127.0.0.1:8080", hidden: false } +}; +const defaultEnvironmentVariables: EnvironmentVariablesFile = { + ...defaultEnvironmentVariablesFromPrior, + TEST1: { value: "true", hidden: true }, + TEST2: "true" +}; + +// Beanstalk __URL +export const integrationUrl = "http://" + (process.env.BUILD_APP_URL || `localhost:${process.env.PORT || "8081"}`); + +let sharedPpaasTestId: PpaasTestId | undefined; +let sharedTestData: TestData | undefined; +let sharedScheduledTestData: TestData | undefined; + +function appendFileData (formData: FormData, formName: string, fileData: string | FileData) { + if (typeof fileData === "string") { + formData.append(formName, fileData); + } else { + formData.append(formName, fileData.value, fileData.options); + } +} + +function convertFormDataPostToFormData (formDataPost: Partial): FormData { + const formData: FormData = new FormData(); + // yamlFile: FileData | string; + if (formDataPost.yamlFile) { + appendFileData(formData, "yamlFile", formDataPost.yamlFile); + } + // additionalFiles?: FileData | string | (FileData | string)[]; + if (formDataPost.additionalFiles) { + if (Array.isArray(formDataPost.additionalFiles)) { + for (const additionalFile of formDataPost.additionalFiles) { + appendFileData(formData, "additionalFiles", additionalFile); + } + } else { + appendFileData(formData, "additionalFiles", formDataPost.additionalFiles); + } + } + // queueName: string; + formData.append("queueName", formDataPost.queueName); + // testId?: string; + if (formDataPost.testId) { formData.append("testId", formDataPost.testId); } + // environmentVariables?: string; + if (formDataPost.environmentVariables !== undefined) { formData.append("environmentVariables", formDataPost.environmentVariables); } + // version?: string; + if (formDataPost.version !== undefined) { formData.append("version", formDataPost.version); } + // restartOnFailure?: "true" | "false"; + if (formDataPost.restartOnFailure !== undefined) { formData.append("restartOnFailure", formDataPost.restartOnFailure); } + // scheduleDate?: number; + if (formDataPost.scheduleDate !== undefined) { formData.append("scheduleDate", formDataPost.scheduleDate); } + // daysOfWeek?: number | number[] | string; + if (formDataPost.daysOfWeek !== undefined) { formData.append("daysOfWeek", formDataPost.daysOfWeek); } + // endDate?: number; + if (formDataPost.endDate !== undefined) { formData.append("endDate", formDataPost.endDate); } + + return formData; +} + +function convertFormDataPutToFormData (formDataPut: FormDataPut): FormData { + const formData: FormData = new FormData(); + // yamlFile: FileData | string; + appendFileData(formData, "yamlFile", formDataPut.yamlFile); + formData.append("testId", formDataPut.testId); + return formData; +} + +export async function getPpaasTestId (): Promise { + if (sharedPpaasTestId) { return sharedPpaasTestId; } + await initSharedTestData(); + return sharedPpaasTestId!; +} + +export async function getTestData (): Promise { + if (sharedTestData) { return sharedTestData; } + await initSharedTestData(); + return sharedTestData!; +} + +export async function getScheduledTestData (): Promise { + if (sharedScheduledTestData) { return sharedScheduledTestData; } + await initSharedScheduledTestData(); + return sharedScheduledTestData!; +} + +export function unsetScheduledTestData (): void { + sharedScheduledTestData = undefined; +} + +async function initSharedTestData (): Promise { + if (sharedPpaasTestId && sharedTestData) { return; } + const url: string = integrationUrl + API_TEST; + log("smoke tests url=" + url, LogLevel.DEBUG); + try { + const queueNames: string[] = await getQueueNames(); + const filename: string = path.basename(BASIC_FILEPATH); + log("POST /test queueNames", LogLevel.DEBUG, queueNames); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(BASIC_FILEPATH), + options: { filename } + }, + queueName: queueNames[0] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + const res: Response = await fetch(url, { + method: "POST", + data, + headers + }); + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = res.data; + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(typeof body.testId, "typeof testId").to.equal("string"); + expect(typeof body.s3Folder, "typeof s3Folder").to.equal("string"); + expect(body.status).to.equal(TestStatus.Created); + sharedTestData = body; + sharedPpaasTestId = PpaasTestId.getFromTestId(body.testId); + } catch (error) { + log("POST /test error", LogLevel.ERROR, error); + throw error; + } +} + +async function initSharedScheduledTestData (): Promise { + if (sharedScheduledTestData) { return; } + const url: string = integrationUrl + API_TEST; + log("smoke tests url=" + url, LogLevel.DEBUG); + try { + const queueNames: string[] = await getQueueNames(); + const filename: string = path.basename(BASIC_FILEPATH); + log("POST /test queueNames", LogLevel.DEBUG, queueNames); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(BASIC_FILEPATH), + options: { filename } + }, + queueName: queueNames[0], + scheduleDate: Date.now() + 600000 + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + const res: Response = await fetch(url, { + method: "POST", + data, + headers + }); + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = res.data; + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(typeof body.testId, "typeof testId").to.equal("string"); + expect(typeof body.s3Folder, "typeof s3Folder").to.equal("string"); + expect(body.status).to.equal(TestStatus.Scheduled); + sharedScheduledTestData = body; + } catch (error) { + log("POST /test error", LogLevel.ERROR, error); + throw error; + } +} + +describe("Test API Integration", () => { + const basicFilepath: string = BASIC_FILEPATH; + const basicFilepathWithEnv: string = BASIC_FILEPATH_WITH_ENV; + let testIdWithEnv: string | undefined; + let testIdWithFiles: string | undefined; + let testIdWithVersion: string | undefined; + let url: string; + let queueName: string = "unittests"; + let numberedVersion: string; + + before(async () => { + url = integrationUrl + API_TEST; + log("smoke tests url=" + url, LogLevel.DEBUG); + const sharedQueueNames = await getQueueNames(); + const sharedPewPewVersions = await getPewPewVersions(); + expect(sharedQueueNames, "sharedQueueNames").to.not.equal(undefined); + expect(sharedQueueNames!.length, "sharedQueueNames.length").to.be.greaterThan(0); + queueName = sharedQueueNames[0]; + log("queueName", LogLevel.DEBUG, { queueName }); + expect(sharedPewPewVersions, "sharedPewPewVersions").to.not.equal(undefined); + expect(sharedPewPewVersions!.length, "sharedPewPewVersions.length").to.be.greaterThan(0); + numberedVersion = sharedPewPewVersions!.find((pewpewVersion: string) => pewpewVersion !== latestPewPewVersion) || ""; + expect(numberedVersion).to.not.equal(undefined); + expect(numberedVersion).to.not.equal(""); + log("numberedVersion", LogLevel.DEBUG, { numberedVersion }); + }); + + describe("POST /test", () => { + before(() => getQueueNames()); + + it("POST /test should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + // testId = body.testId; + // If this runs before the other acceptance tests populate the shared data + sharedTestData = body; + sharedPpaasTestId = PpaasTestId.getFromTestId(body.testId); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with version latest should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + version: latestPewPewVersion, + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + // testId = body.testId; + // If this runs before the other acceptance tests populate the shared data + sharedTestData = body; + sharedPpaasTestId = PpaasTestId.getFromTestId(body.testId); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with version numbered should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const environmentVariables: EnvironmentVariablesFile = { + PROFILE: { value: "version", hidden: false } + }; + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + version: numberedVersion, + environmentVariables: JSON.stringify(environmentVariables), + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + testIdWithVersion = body.testId; + // We can't use this for shared since it has version different + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with version bogus should respond 400 Bad Request", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + version: "bogus", + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("invalid version"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with extra options should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const environmentVariables: EnvironmentVariablesFile = { + ...defaultEnvironmentVariables, + NOT_NEEDED: { value: "true", hidden: false }, + ALSO_NOT_NEEDED: { value: "false", hidden: true } + }; + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + restartOnFailure: "true", + environmentVariables: JSON.stringify(environmentVariables), + additionalFiles: [{ + value: createReadStream(NOT_YAML_FILEPATH), + options: { filename: path.basename(NOT_YAML_FILEPATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test missing vars should respond 400 Bad Request", (done: Mocha.Done) => { + const filepath: string = basicFilepathWithEnv; + const filename: string = path.basename(filepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("SERVICE_URL_AGENT"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test missing files should respond 400 Bad Request", (done: Mocha.Done) => { + const filepath: string = BASIC_FILEPATH_WITH_FILES; + const filename: string = path.basename(filepath); + const extrafilename: string = path.basename(NOT_YAML_FILEPATH); + const extrafilename2: string = path.basename(NOT_YAML_FILEPATH2); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName, + additionalFiles: [{ + value: createReadStream(NOT_YAML_FILEPATH), + options: { filename: extrafilename } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include(extrafilename2); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with vars should respond 200 OK", (done: Mocha.Done) => { + const filepath: string = basicFilepathWithEnv; + const filename: string = path.basename(filepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName, + environmentVariables: JSON.stringify(defaultEnvironmentVariables) + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + testIdWithEnv = body.testId; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with files should respond 200 OK", (done: Mocha.Done) => { + const filepath: string = BASIC_FILEPATH_WITH_FILES; + const filename: string = path.basename(filepath); + const extrafilepath: string = NOT_YAML_FILEPATH; + const extrafilename: string = path.basename(extrafilepath); + const extrafilepath2: string = NOT_YAML_FILEPATH2; + const extrafilename2: string = path.basename(extrafilepath2); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName, + additionalFiles: [{ + value: createReadStream(extrafilepath), + options: { filename: extrafilename } + },{ + value: createReadStream(extrafilepath2), + options: { filename: extrafilename2 } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + testIdWithFiles = body.testId; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with no peak load should respond 200 OK", (done: Mocha.Done) => { + const filepath: string = BASIC_FILEPATH_NO_PEAK_LOAD; + const filename: string = path.basename(filepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with headers_all should respond 200 OK", (done: Mocha.Done) => { + const filepath: string = BASIC_FILEPATH_HEADERS_ALL; + const filename: string = path.basename(filepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(filepath), + options: { filename } + }, + queueName + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with zip yaml with vars should response 200 OK", (done: Mocha.Done) => { + const environmentVariables: EnvironmentVariablesFile = { + ...defaultEnvironmentVariables, + NOT_NEEDED: { value: "true", hidden: false }, + ALSO_NOT_NEEDED: { value: "false", hidden: true } + }; + const formData: Partial = { + queueName, + restartOnFailure: "true", + environmentVariables: JSON.stringify(environmentVariables), + additionalFiles: [{ + value: createReadStream(ZIP_TEST_YAML_ENV_PATH), + options: { filename: path.basename(ZIP_TEST_YAML_ENV_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test zip yaml missing vars should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: Partial = { + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_YAML_ENV_PATH), + options: { filename: path.basename(ZIP_TEST_YAML_ENV_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("SERVICE_URL_AGENT"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test yaml and zip yaml should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_YAML_PATH), + options: { filename: path.basename(ZIP_TEST_YAML_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("Received multiple yamlFiles"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test zip yaml multiple yaml missing vars should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: Partial = { + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_YAMLS_PATH), + options: { filename: path.basename(ZIP_TEST_YAMLS_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("Received multiple yamlFiles"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with zip yaml and zip files should respond 200 OK", (done: Mocha.Done) => { + const formData: Partial = { + queueName, + restartOnFailure: "true", + environmentVariables: JSON.stringify({ + ...defaultEnvironmentVariables, + NOT_NEEDED: { value: "true", hidden: false }, + ALSO_NOT_NEEDED: { value: "false", hidden: true } + }), + additionalFiles: [{ + value: createReadStream(ZIP_TEST_YAML_ENV_PATH), + options: { filename: path.basename(ZIP_TEST_YAML_ENV_PATH) } + }, + { + value: createReadStream(ZIP_TEST_FILES_PATH), + options: { filename: path.basename(ZIP_TEST_FILES_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with yaml and zip files should respond 200 OK", (done: Mocha.Done) => { + const filePath = BASIC_FILEPATH_WITH_FILES; + const formData: Partial = { + yamlFile: { + value: createReadStream(filePath), + options: { filename: path.basename(filePath) } + }, + queueName, + restartOnFailure: "true", + environmentVariables: JSON.stringify({ NOT_NEEDED: "true", ALSO_NOT_NEEDED: "false" }), + additionalFiles: [{ + value: createReadStream(ZIP_TEST_FILES_PATH), + options: { filename: path.basename(ZIP_TEST_FILES_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.testId).to.not.equal(undefined); + expect(body.s3Folder).to.not.equal(undefined); + expect(body.status).to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test invalid zip should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_INVALID_PATH), + options: { filename: path.basename(ZIP_TEST_INVALID_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("end of central directory record signature not found"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test directory zip should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_DIR_PATH), + options: { filename: path.basename(ZIP_TEST_DIR_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("Zip files with directories are not supported"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test over 10 zip should respond 400 Bad Request", (done: Mocha.Done) => { + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + queueName, + additionalFiles: [{ + value: createReadStream(ZIP_TEST_FILES_11_PATH), + options: { filename: path.basename(ZIP_TEST_FILES_11_PATH) } + }] + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("has more than 10 files"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: Date.now() + 600000 + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Scheduled); + expect(body.startTime, "startTime").to.equal(formData.scheduleDate); + expect(body.endTime, "endTime").to.be.greaterThan(formData.scheduleDate!); + const ppaasTestId = PpaasTestId.getFromTestId(body.testId); + // We can't re-use the schedule date for the testId since we don't want conflicts if you schedule the same test twice + expect(ppaasTestId.date.getTime(), "ppaasTestId.date").to.not.equal(formData.scheduleDate); + // If this runs before the other acceptance tests populate the shared data + sharedScheduledTestData = body; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled in the past should respond 400 Bad Request", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: Date.now() - 600000 + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + const body: TestManagerError = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("Could not addTest"); + expect(body.error).to.not.equal(undefined); + expect(body.error).to.include("past"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled in the invalid date should respond 400 Bad Request", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: Omit & { scheduleDate: string } = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: "bad" + }; + const data = convertFormDataPostToFormData(formData as any); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + const body: TestManagerError = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("invalid scheduleDate"); + expect(body.error).to.not.equal(undefined); + expect(body.error).to.include("not a number"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled recurring should respond 200 OK", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: Date.now() + 600000, + endDate: Date.now() + (7 * 24 * 60 * 60000), + daysOfWeek: JSON.stringify([0,1,2,3,4,5,6]) + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Scheduled); + expect(body.startTime, "startTime").to.equal(formData.scheduleDate); + expect(body.endTime, "endTime").to.be.greaterThan(formData.scheduleDate!); + const ppaasTestId = PpaasTestId.getFromTestId(body.testId); + // We can't re-use the schedule date for the testId since we don't want conflicts if you schedule the same test twice + expect(ppaasTestId.date.getTime(), "ppaasTestId.date").to.not.equal(formData.scheduleDate); + // If this runs before the other acceptance tests populate the shared data + sharedScheduledTestData = body; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled recurring no endDate should respond 400 Bad Request", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: Date.now() + 600000, + daysOfWeek: JSON.stringify([0,1,2,3,4,5,6]) + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + const body: TestManagerError = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("both daysOfWeek and endDate"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled recurring no daysOfWeek should respond 400 Bad Request", (done: Mocha.Done) => { + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename } + }, + queueName, + scheduleDate: Date.now() + 600000, + endDate: Date.now() + 6000000 + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + const body: TestManagerError = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body).to.not.equal(undefined); + expect(body.message).to.not.equal(undefined); + expect(body.message).to.include("both daysOfWeek and endDate"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(sharedPpaasTestId, "sharedPpaasTestId").to.not.equal(undefined); + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: filename, + queueName, + testId: sharedPpaasTestId!.testId + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(sharedPpaasTestId!.testId); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Created); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test missing hidden vars and prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithEnv, "testIdWithEnv").to.not.equal(undefined); + const filename: string = path.basename(basicFilepathWithEnv); + const changedVars: EnvironmentVariablesFile = { + ...defaultEnvironmentVariablesFromPrior, + TEST2: "false" + }; + const formData: FormDataPost = { + yamlFile: filename, + queueName, + environmentVariables: JSON.stringify(changedVars), + testId: testIdWithEnv! + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData with vars and prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("TEST1"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test missing legacy vars and prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithEnv, "testIdWithEnv").to.not.equal(undefined); + const filename: string = path.basename(basicFilepathWithEnv); + const changedVars: EnvironmentVariablesFile = { + ...defaultEnvironmentVariablesFromPrior, + TEST1: { value: "false", hidden: true } + }; + const formData: FormDataPost = { + yamlFile: filename, + queueName, + environmentVariables: JSON.stringify(changedVars), + testId: testIdWithEnv! + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData with vars and prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include("TEST2"); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test with vars and prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithEnv, "testIdWithEnv").to.not.equal(undefined); + const filename: string = path.basename(basicFilepathWithEnv); + const changedVars: EnvironmentVariablesFile = { + ...defaultEnvironmentVariablesFromPrior, + TEST1: { value: "false", hidden: true }, + TEST2: "false" + }; + const formData: FormDataPost = { + yamlFile: filename, + queueName, + environmentVariables: JSON.stringify(changedVars), + testId: testIdWithEnv! + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData with vars and prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(testIdWithEnv); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Created); + // If this runs before the other acceptance tests populate the shared data + testIdWithEnv = body.testId; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + // In this case, even though the previous test has the files, if we don't pass them in to the fields it should fail + it("POST /test missing files prior testId should respond 400 Bad Request", (done: Mocha.Done) => { + expect(testIdWithFiles, "testIdWithFiles").to.not.equal(undefined); + const filename: string = path.basename(BASIC_FILEPATH_WITH_FILES); + const extrafilename: string = path.basename(NOT_YAML_FILEPATH); + const extrafilename2: string = path.basename(NOT_YAML_FILEPATH2); + const formData: FormDataPost = { + yamlFile: filename, + queueName, + testId: testIdWithFiles!, + additionalFiles: extrafilename + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include(extrafilename2); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test missing files prior testId, new yaml needs files should respond 400 Bad Request", (done: Mocha.Done) => { + expect(sharedPpaasTestId, "sharedPpaasTestId").to.not.equal(undefined); + const filename: string = path.basename(BASIC_FILEPATH_WITH_FILES); + const extrafilename: string = path.basename(NOT_YAML_FILEPATH); + const formData: FormDataPost = { + yamlFile: { + value: createReadStream(BASIC_FILEPATH_WITH_FILES), + options: { filename } + }, + queueName, + testId: sharedPpaasTestId!.testId + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + log("body: " + bodyText, LogLevel.DEBUG, bodyText); + expect(bodyText).to.not.equal(undefined); + expect(bodyText).to.include(extrafilename); + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + // Now we pass in the prior files + it("POST /test with files prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithFiles, "testIdWithFiles").to.not.equal(undefined); + const filename: string = path.basename(BASIC_FILEPATH_WITH_FILES); + const extrafilename: string = path.basename(NOT_YAML_FILEPATH); + const extrafilename2: string = path.basename(NOT_YAML_FILEPATH2); + const formData: FormDataPost = { + yamlFile: filename, + additionalFiles: JSON.stringify([extrafilename, extrafilename2]), + queueName, + testId: testIdWithFiles! + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData with files prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Created); + // If this runs before the other acceptance tests populate the shared data + testIdWithFiles = body.testId; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + // Now we pass in the prior files + it("POST /test with files prior testId one changed file should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithFiles, "testIdWithFiles").to.not.equal(undefined); + const filename: string = path.basename(BASIC_FILEPATH_WITH_FILES); + const extrafilename: string = path.basename(NOT_YAML_FILEPATH); + const extrafilename2: string = path.basename(NOT_YAML_FILEPATH2); + const formData: FormDataPost = { + yamlFile: filename, + additionalFiles: [{ + value: createReadStream(NOT_YAML_FILEPATH), + options: { filename: extrafilename } + }, extrafilename2], + queueName, + testId: testIdWithFiles! + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData with files prior testId", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Created); + // If this runs before the other acceptance tests populate the shared data + testIdWithFiles = body.testId; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("POST /test scheduled prior testId should respond 200 OK", (done: Mocha.Done) => { + expect(sharedPpaasTestId).to.not.equal(undefined); + const filename: string = path.basename(basicFilepath); + const formData: FormDataPost = { + yamlFile: filename, + queueName, + testId: sharedPpaasTestId!.testId, + scheduleDate: Date.now() + 600000 + }; + const data = convertFormDataPostToFormData(formData); + const headers = data.getHeaders(); + log("POST formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "POST", + data, + headers + }).then((res: Response) => { + log("POST /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + const body: TestData = JSON.parse(bodyText); + log("body: " + bodyText, LogLevel.DEBUG, body); + expect(body, "body").to.not.equal(undefined); + expect(body.testId, "testId").to.not.equal(undefined); + expect(body.s3Folder, "s3Folder").to.not.equal(undefined); + expect(body.status, "status").to.equal(TestStatus.Scheduled); + expect(body.startTime, "startTime").to.equal(formData.scheduleDate); + expect(body.endTime, "endTime").to.be.greaterThan(formData.scheduleDate!); + const ppaasTestId = PpaasTestId.getFromTestId(body.testId); + // We can't re-use the schedule date for the testId since we don't want conflicts if you schedule the same test twice + expect(ppaasTestId.date.getTime(), "ppaasTestId.date").to.not.equal(formData.scheduleDate); + // If this runs before the other acceptance tests populate the shared data + sharedScheduledTestData = body; + done(); + }).catch((error) => { + log("POST /test error", LogLevel.ERROR, error); + done(error); + }); + }); + }); + + describe("GET /test", () => { + it("GET /test should respond 200 OK", (done: Mocha.Done) => { + fetch(url).then((res) => { + log("GET /test response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + const tests: unknown = res.data; + log("tests: " + tests, LogLevel.DEBUG, tests); + expect(tests && typeof tests === "object" && "runningTests" in tests).to.equal(true); + expect(tests && typeof tests === "object" && "recentTests" in tests).to.equal(true); + expect(tests && typeof tests === "object" && "requestedTests" in tests).to.equal(true); + const allTests = tests as AllTests; + expect(allTests.runningTests).to.not.equal(undefined); + expect(Array.isArray(allTests.runningTests)).to.equal(true); + expect(allTests.recentTests).to.not.equal(undefined); + expect(Array.isArray(allTests.recentTests)).to.equal(true); + expect(allTests.requestedTests).to.not.equal(undefined); + expect(Array.isArray(allTests.requestedTests)).to.equal(true); + done(); + }).catch((error) => done(error)); + }); + + it("GET /test?testId=invalid should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url + "?testId=invalid").then((res) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /test?testId=validButNotInS3 should respond 404 Not Found", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").testId; + log("validButNotInS3 testId: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?testId=" + validButNotInS3).then((res) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + describe("GET /test populated", () => { + before (async () => { + await getPpaasTestId(); + }); + + afterEach (async () => { + const res = await fetch(url); + expect(res.status).to.equal(200); + const tests: unknown = res.data; + log("test populated: " + tests, LogLevel.DEBUG, tests); + expect(tests && typeof tests === "object" && "runningTests" in tests).to.equal(true); + expect(tests && typeof tests === "object" && "recentTests" in tests).to.equal(true); + expect(tests && typeof tests === "object" && "requestedTests" in tests).to.equal(true); + const allTests = tests as AllTests; + expect(allTests.runningTests, "runningTests").to.not.equal(undefined); + expect(Array.isArray(allTests.runningTests), "runningTests").to.equal(true); + expect(allTests.recentTests, "recentTests").to.not.equal(undefined); + expect(Array.isArray(allTests.recentTests), "recentTests").to.equal(true); + expect(allTests.requestedTests, "requestedTests").to.not.equal(undefined); + expect(Array.isArray(allTests.requestedTests), "requestedTests").to.equal(true); + // Running should have at least one now + expect(allTests.runningTests.length, "tests.runningTests.length: " + allTests.runningTests.length).to.be.greaterThan(0); + expect(allTests.recentTests.length, "tests.recentTests.length: " + allTests.recentTests.length).to.equal(0); + expect(allTests.requestedTests.length, "tests.requestedTests.length: " + allTests.requestedTests.length).to.be.greaterThanOrEqual(0); + }); + + it("GET /test?testId=validInS3 should respond 200 OK", (done: Mocha.Done) => { + expect(sharedPpaasTestId).to.not.equal(undefined); + const testId = sharedPpaasTestId!.testId; + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?testId=" + testId).then((res) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status).to.equal(200); + done(); + }).catch((error) => done(error)); + }); + }); + }); + + describe("GET /test?newTest", () => { + before (async () => { + await getPpaasTestId(); + }); + + it("GET /test?newTest&testId=invalid should respond 400 Bad Request", (done: Mocha.Done) => { + fetch(url + "?newTest&testId=invalid").then((res) => { + expect(res.status).to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET /test?newTest&testId=validButNotInS3 should respond 404 Not Found", (done: Mocha.Done) => { + const validButNotInS3 = PpaasTestId.makeTestId("validButNotInS3").testId; + log("validButNotInS3 testId: " + validButNotInS3, LogLevel.DEBUG, validButNotInS3); + fetch(url + "?newTest&testId=" + validButNotInS3).then((res) => { + expect(res.status).to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET /test?newTest&testId=validInS3 should respond 200 OK", (done: Mocha.Done) => { + expect(sharedPpaasTestId).to.not.equal(undefined); + const testId = sharedPpaasTestId!.testId; + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?newTest&testId=" + testId).then((res) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as PreviousTestData).testId, "typeof testId").to.equal("string"); + expect(typeof (body as PreviousTestData).s3Folder, "typeof s3Folder").to.equal("string"); + expect(typeof (body as PreviousTestData).yamlFile, "typeof yamlFile").to.equal("string"); + expect(typeof (body as PreviousTestData).version, "typeof version").to.equal("string"); + expect(typeof (body as PreviousTestData).environmentVariables, "typeof environmentVariables").to.equal("object"); + const test: PreviousTestData = body as any; + log("tests: " + test, LogLevel.DEBUG, test); + expect(test.testId, "testId").to.equal(testId); + expect(test.s3Folder, "s3Folder").to.equal(sharedPpaasTestId!.s3Folder); + expect(test.yamlFile, "yamlFile").to.equal(path.basename(basicFilepath)); + expect(test.queueName, "queueName").to.equal(queueName); + expect(test.additionalFiles, "additionalFiles").to.equal(undefined); + expect(test.version, "version").to.equal(latestPewPewVersion); + expect(test.environmentVariables, "environmentVariables").to.not.equal(undefined); + expect(Object.keys(test.environmentVariables).length, "environmentVariables.keys.length: " + Object.keys(test.environmentVariables)).to.equal(0); + expect(test.restartOnFailure, "restartOnFailure").to.equal(undefined); + expect(test.bypassParser, "bypassParser").to.equal(undefined); + expect(test.scheduleDate, "scheduleDate").to.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + + it("GET /test?newTest&testId=validInS3WithVersion should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithVersion).to.not.equal(undefined); + const testId = testIdWithVersion; + log("validInS3 testId: " + testId, LogLevel.DEBUG, testId); + fetch(url + "?newTest&testId=" + testId).then((res) => { + log ("validInS3 response", LogLevel.DEBUG, res); + expect(res.status, JSON.stringify(res.data)).to.equal(200); + const body: unknown = res.data; + expect(typeof body, "typeof body").to.equal("object"); + expect(typeof (body as PreviousTestData).testId, "typeof testId").to.equal("string"); + expect(typeof (body as PreviousTestData).s3Folder, "typeof s3Folder").to.equal("string"); + expect(typeof (body as PreviousTestData).yamlFile, "typeof yamlFile").to.equal("string"); + expect(typeof (body as PreviousTestData).version, "typeof version").to.equal("string"); + expect(typeof (body as PreviousTestData).environmentVariables, "typeof environmentVariables").to.equal("object"); + const test: PreviousTestData = body as any; + log("tests: " + test, LogLevel.DEBUG, test); + expect(test.testId, "testId").to.equal(testId); + expect(test.s3Folder, "s3Folder").to.not.equal(undefined); + expect(test.yamlFile, "yamlFile").to.equal(path.basename(basicFilepath)); + expect(test.queueName, "queueName").to.equal(queueName); + expect(test.additionalFiles, "additionalFiles").to.equal(undefined); + expect(test.version, "version").to.equal(numberedVersion); + expect(test.environmentVariables, "environmentVariables").to.not.equal(undefined); + expect(Object.keys(test.environmentVariables).length, "environmentVariables.keys.length: " + Object.keys(test.environmentVariables)).to.equal(1); + expect(test.restartOnFailure, "restartOnFailure").to.equal(undefined); + expect(test.bypassParser, "bypassParser").to.equal(undefined); + expect(test.scheduleDate, "scheduleDate").to.equal(undefined); + done(); + }).catch((error) => done(error)); + }); + }); + + describe("PUT /test", () => { + before (async () => { + await getPpaasTestId(); + }); + + it("PUT /test basic should respond 200 OK", (done: Mocha.Done) => { + expect(sharedPpaasTestId).to.not.equal(undefined); + const formData: FormDataPut = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + testId: sharedPpaasTestId!.testId + }; + const data = convertFormDataPutToFormData(formData); + const headers = data.getHeaders(); + log("PUT formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "PUT", + data, + headers + }).then((res: Response) => { + log("PUT /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + done(); + }).catch((error) => { + log("PUT /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PUT /test with environment variables should respond 200 OK", (done: Mocha.Done) => { + expect(testIdWithEnv).to.not.equal(undefined); + const formData: FormDataPut = { + yamlFile: { + value: createReadStream(basicFilepathWithEnv), + options: { filename: path.basename(basicFilepathWithEnv) } + }, + testId: testIdWithEnv! + }; + const data = convertFormDataPutToFormData(formData); + const headers = data.getHeaders(); + log("PUT formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "PUT", + data, + headers + }).then((res: Response) => { + log("PUT /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(200); + done(); + }).catch((error) => { + log("PUT /test error", LogLevel.ERROR, error); + done(error); + }); + }); + + it("PUT /test with wrong yaml file should respond 400 Bad Request", (done: Mocha.Done) => { + expect(testIdWithEnv).to.not.equal(undefined); + const formData: FormDataPut = { + yamlFile: { + value: createReadStream(basicFilepath), + options: { filename: path.basename(basicFilepath) } + }, + testId: testIdWithEnv! + }; + const data = convertFormDataPutToFormData(formData); + const headers = data.getHeaders(); + log("PUT formData", LogLevel.DEBUG, { test: formData, headers }); + fetch(url, { + method: "PUT", + data, + headers + }).then((res: Response) => { + log("PUT /test res", LogLevel.DEBUG, res); + const bodyText = JSON.stringify(res.data); + expect(res.status, bodyText).to.equal(400); + done(); + }).catch((error) => { + log("PUT /test error", LogLevel.ERROR, error); + done(error); + }); + }); + }); +}); diff --git a/controller/acceptance/yamlFile.spec.ts b/controller/acceptance/yamlFile.spec.ts new file mode 100644 index 00000000..57c83fdf --- /dev/null +++ b/controller/acceptance/yamlFile.spec.ts @@ -0,0 +1,133 @@ +import { BASIC_FILEPATH, getPpaasTestId, integrationUrl } from "./test.spec"; +import { LogLevel, PpaasTestId, log } from "@fs/ppaas-common"; +import _axios, { AxiosRequestConfig, AxiosResponse as Response } from "axios"; +import { API_YAML } from "../types"; +import { expect } from "chai"; +import path from "path"; + +const REDIRECT_TO_S3: boolean = process.env.REDIRECT_TO_S3 === "true"; + +async function fetch ( + url: string, + config?: AxiosRequestConfig +): Promise { + try { + const response: Response = await _axios({ + method: config?.method || "get", + url, + maxRedirects: 0, + validateStatus: (status) => status < 500, // Resolve only if the status code is less than 500 + ...(config || {}) + }); + return response; + } catch (error) { + throw error; + } +} + +describe("YamlFile API Integration", function () { + let url: string; + let yamlFile: string | undefined; + let dateString: string | undefined; + let yamlFilename: string | undefined; + + // We can't use an arrow function here if we want to increase the timeout + // https://stackoverflow.com/questions/41949895/how-to-set-timeout-on-before-hook-in-mocha + before(async function (): Promise { + this.timeout(60000); + url = integrationUrl + API_YAML; + log("smoke tests url=" + url, LogLevel.DEBUG); + const ppaasTestId: PpaasTestId = await getPpaasTestId(); + yamlFile = ppaasTestId.yamlFile; + dateString = ppaasTestId.dateString; + yamlFilename = path.basename(BASIC_FILEPATH); // The yaml file for the shared getPpaasTestId() + expect(yamlFile, "yamlFile").to.not.equal(undefined); + expect(dateString, "dateString").to.not.equal(undefined); + expect(yamlFilename, "yamlFilename").to.not.equal(undefined); + }); + + it("GET yaml should respond 404 Not Found", (done: Mocha.Done) => { + fetch(url).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined) { done(new Error("No yamlFile")); return; } + fetch(url + `/${yamlFile}`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile/dateString should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile/dateString/notyaml should respond 400 Bad Request", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}/${yamlFile}.json`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(400); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile/dateString/notins3.yaml should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}/notins3.yaml`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile/dateString/notins3.yml should respond 404 Not Found", (done: Mocha.Done) => { + if (yamlFile === undefined || dateString === undefined) { done(new Error("No yamlFile or dateString")); return; } + fetch(url + `/${yamlFile}/${dateString}/notins3.yml`).then((res: Response) => { + expect(res, "res").to.not.equal(undefined); + expect(res.status, "status").to.equal(404); + done(); + }).catch((error) => done(error)); + }); + + it("GET yaml/yamlFile/datestring/yaml-ins3.yaml should respond 200", (done: Mocha.Done) => { + if (yamlFilename === undefined) { done(new Error("No yamlFilename")); return; } + log(url + `/${yamlFile}/${dateString}/${yamlFilename}`, LogLevel.WARN); + fetch(url + `/${yamlFile}/${dateString}/${yamlFilename}`).then((res: Response) => { + log(`GET ${url}/${yamlFile}/${dateString}/${yamlFilename}`, LogLevel.DEBUG, { status: res.status, data: res.data }); + expect(res, "res").to.not.equal(undefined); + if (REDIRECT_TO_S3) { + expect(res.status, "status").to.equal(302); + expect(res.headers.location, "location").to.not.equal(undefined); + expect(typeof res.headers.location, "typeof location").to.equal("string"); + const location = res.headers.location; + log(`GET ${location}`, LogLevel.DEBUG); + fetch(location).then((redirectResponse: Response) => { + log(`GET ${location} response`, LogLevel.DEBUG, { status: redirectResponse?.status, headers: redirectResponse.headers, data: redirectResponse.data }); + expect(redirectResponse.status, "status").to.equal(200); + expect(redirectResponse.data, "body").to.not.equal(undefined); + expect(typeof redirectResponse.data, "typeof redirectResponse.data").to.equal("string"); + expect(redirectResponse.data, "redirectResponse.data").to.include("load_pattern:"); + expect(redirectResponse.data, "redirectResponse.data").to.include("endpoints:"); + done(); + }).catch((error) => done(error)); + } else { + expect(res.status, "status").to.equal(200); + expect(res.data, "data").to.not.equal(undefined); + expect(typeof res.data, "typeof data").to.equal("string"); + expect(res.data, "res.data").to.include("load_pattern:"); + expect(res.data, "res.data").to.include("endpoints:"); + done(); + } + }).catch((error) => done(error)); + }); +}); diff --git a/controller/components/Alert/index.tsx b/controller/components/Alert/index.tsx new file mode 100644 index 00000000..7c2ac17f --- /dev/null +++ b/controller/components/Alert/index.tsx @@ -0,0 +1,48 @@ +import Div from "../Div"; +import styled from "styled-components"; + +export const Alert = styled(Div)` + padding: .75rem 1.25rem; + border: 1px solid; + border-radius: .25rem; + margin-bottom: .5rem; + margin-top: .5rem; +`; + +export const Success = styled(Alert)` + color: rgb(168, 234, 183); + background-color: rgb(23, 54, 30); + border-top-color: rgb(41, 99, 54); + border-right-color: rgb(41, 99, 54); + border-bottom-color: rgb(41, 99, 54); + border-left-color: rgb(41, 99, 54); +`; + +export const Danger = styled(Alert)` + color: rgb(230, 155, 162); + background-color: rgb(64, 11, 16); + border-top-color: rgb(129, 23, 34); + border-right-color: rgb(129, 23, 34); + border-bottom-color: rgb(129, 23, 34); + border-left-color: rgb(129, 23, 34); +`; + +export const Warning = styled(Alert)` + color: rgb(251, 219, 127); + background-color: rgb(81, 62, 0); + border-top-color: rgb(167, 126, 0); + border-right-color: rgb(167, 126, 0); + border-bottom-color: rgb(167, 126, 0); + border-left-color: rgb(167, 126, 0); +`; + +export const Info = styled(Alert)` + color: rgb(156, 230, 243); + background-color: rgb(18, 54, 60); + border-top-color: rgb(34, 101, 112); + border-right-color: rgb(34, 101, 112); + border-bottom-color: rgb(34, 101, 112); + border-left-color: rgb(34, 101, 112); +`; + +export default Alert; diff --git a/controller/components/Alert/story.tsx b/controller/components/Alert/story.tsx new file mode 100644 index 00000000..09fcdfa7 --- /dev/null +++ b/controller/components/Alert/story.tsx @@ -0,0 +1,20 @@ +import { Alert, Danger, Info, Success, Warning } from "."; +import type { Meta, StoryFn } from "@storybook/react"; +import { GlobalStyle } from "../Layout"; +import React from "react"; + +export default { + title: "Alert", + component: Alert +} as Meta; + +export const Default: StoryFn = () => ( + + + Alert + Success + Danger + Warning + Info + +); diff --git a/controller/components/Calendar/index.tsx b/controller/components/Calendar/index.tsx new file mode 100644 index 00000000..58071307 --- /dev/null +++ b/controller/components/Calendar/index.tsx @@ -0,0 +1,92 @@ +import { CalendarOptions, PluginDef } from "@fullcalendar/core"; +import { LogLevel, log } from "../../pages/api/util/log"; +import React, { useEffect, useState } from "react"; +import { formatPageHref, getHourMinuteFromTimestamp } from "../../pages/api/util/clientutil"; +import Div from "../Div"; +import Script from "next/script"; +import dynamic from "next/dynamic"; +import styled from "styled-components"; + +const CalendarDiv = styled(Div)` +`; + +export type GridView = "timeGridDay" | "timeGridWeek" | "dayGridMonth" | undefined; + +const loadingComponent = () =>
Loading ...
; +const CalendarComponent = dynamic(() => import("@fullcalendar/react"), { + ssr: false, + loading: loadingComponent +}); + +// What this returns or calls from the parents +export type CalendarProps = CalendarOptions + +/** + * Wrapper to allow SSR: false loading of https://github.com/fullcalendar/fullcalendar-react/ + * https://github.com/fullcalendar/fullcalendar-react/issues/17 + * https://www.davidangulo.xyz/posts/how-to-use-fullcalendar-in-next-js/ + * @param props OptionsInput from @fullcalendar + */ +export const PPaaSCalendar = ({ ...calendarProps}: CalendarProps) => { + const [plugins, setPlugins] = useState([]); + + useEffect(() => { + (async () => { + const dayGrid = (await import("@fullcalendar/daygrid")).default; + const timeGrid = (await import("@fullcalendar/timegrid")).default; + const interaction = (await import("@fullcalendar/interaction")).default; + + setPlugins([dayGrid, timeGrid, interaction]); + })(); + }, []); + + // Serverside until the useEffect fires + if (plugins.length === 0) { return loadingComponent(); } + + // If there are any recurring events we need to format them to local time. + // https://github.com/fullcalendar/fullcalendar/issues/5273 + // It has to be client rendered so we get the local browser time zone + if (Array.isArray(calendarProps.events)) { + for (const event of calendarProps.events) { + if(typeof event.startRecur === "number") { + event.startTime = getHourMinuteFromTimestamp(event.startRecur); + event.endTime = typeof event.testRunTimeMn === "number" + ? getHourMinuteFromTimestamp(event.startRecur + (60000 * event.testRunTimeMn)) + : undefined; + log("Updated recurring event", LogLevel.DEBUG, event); + } + if (event.url && typeof event.url === "string") { + // Fix old historical urls that didn't prepend / + event.url = event.url.startsWith("/") ? event.url : ("/" + event.url); + // Add the basePath if needed + event.url = formatPageHref(event.url); + } + } + } + return ( + + {/* https://github.com/fullcalendar/fullcalendar/issues/7284#issuecomment-1563308360 */} +