From e598eb922f8d9fc31447bbd844d57941e5660a97 Mon Sep 17 00:00:00 2001 From: lgorski Date: Fri, 14 Apr 2023 11:43:01 -0500 Subject: [PATCH 01/11] Replace scala generated CF template w CDK --- .gitignore | 2 + cdk/.dwollaci.yml | 50 ++ cdk/cdk.json | 4 + cdk/package-lock.json | 632 ++++++++++++++++++ cdk/package.json | 38 ++ cdk/src/CloudflarePublicHostnameStack.ts | 69 ++ cdk/src/index.ts | 6 + cdk/tsconfig.json | 18 + project/CdkPlugin.scala | 36 + project/build.properties | 2 +- stack/src/it/resources/logback-test.xml | 15 - .../cloudflare/StackIntegrationSpec.scala | 31 - .../cloudflare/CreateTemplate.scala | 13 - .../cloudformation/cloudflare/Stack.scala | 137 ---- .../cloudflare/ConstructorTest.java | 10 - .../cloudformation/cloudflare/StackSpec.scala | 96 --- 16 files changed, 856 insertions(+), 303 deletions(-) create mode 100644 cdk/.dwollaci.yml create mode 100644 cdk/cdk.json create mode 100644 cdk/package-lock.json create mode 100644 cdk/package.json create mode 100644 cdk/src/CloudflarePublicHostnameStack.ts create mode 100644 cdk/src/index.ts create mode 100644 cdk/tsconfig.json create mode 100644 project/CdkPlugin.scala delete mode 100644 stack/src/it/resources/logback-test.xml delete mode 100644 stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala delete mode 100644 stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala delete mode 100644 stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala delete mode 100644 stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java delete mode 100644 stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala diff --git a/.gitignore b/.gitignore index 5ac0569..a5c2ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea/ target/ project/project/ +node_modules/ +.bsp/ \ No newline at end of file diff --git a/cdk/.dwollaci.yml b/cdk/.dwollaci.yml new file mode 100644 index 0000000..cdf29c2 --- /dev/null +++ b/cdk/.dwollaci.yml @@ -0,0 +1,50 @@ +stages: + build: + nodeLabel: jetstream + steps: + - make --directory tasks all + - ENVIRONMENT=Admin make --directory tasks common/docker/jenkins/ref/jenkins.yml + - make --directory stack jenkins-build + filesToStash: + - common/docker/** + - stack/Berksfile + - stack/Berksfile.lock + - stack/Makefile + - stack/stack.json + - stack/policies/** + - stack/docker-compose.yml + - stack/bootstrap.sh + - tasks/** + prepublish: + nodeLabel: chef-deployer + steps: + - make --directory stack jenkins-prepublish + dockerPublish: + dockerImages: + - imageName: jenkins + dockerfile: common/docker/Dockerfile + context: . + buildArgs: + VCS_REF: '{{GIT_COMMIT}}' + VCS_URL: '{{GIT_URL}}' + BUILD_DATE: '{{DATE}}' + VERSION: '{{GIT_COMMIT}}' + JENKINS_VERSION: "2.387.1" + destinations: + - registry: docker.dwolla.net/dwolla + tags: + - '{{GIT_COMMIT}}' + - latest + deployProd: + nodeLabel: jetstream-deployer + steps: + - ENVIRONMENT=Admin make --directory tasks deploy + - | + cd stack && \ + jetstream provision \ + --revision $GIT_COMMIT \ + --environment Admin \ + --region us-west-2 \ + --config ./stack.json \ + --skip-waiting \ + --volume-id vol-09fa6d8bed5eac248 diff --git a/cdk/cdk.json b/cdk/cdk.json new file mode 100644 index 0000000..762cf97 --- /dev/null +++ b/cdk/cdk.json @@ -0,0 +1,4 @@ +{ + "app": "npx ts-node src/index.ts", + "versionReporting": false +} \ No newline at end of file diff --git a/cdk/package-lock.json b/cdk/package-lock.json new file mode 100644 index 0000000..a3e4185 --- /dev/null +++ b/cdk/package-lock.json @@ -0,0 +1,632 @@ +{ + "name": "cdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cdk", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@aws-cdk/aws-codestar-alpha": "2.41.0-alpha.0", + "aws-cdk": "2.41.0", + "aws-cdk-lib": "2.41.0", + "constructs": "10.1.107", + "dotenv": "16.0.2", + "prettier": "2.7.1", + "rimraf": "^3.0.2" + }, + "devDependencies": { + "@types/node": "18.7.18", + "mocked-env": "1.3.5", + "ts-node": "10.9.1", + "typescript": "4.8.3" + } + }, + "node_modules/@aws-cdk/aws-codestar-alpha": { + "version": "2.41.0-alpha.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/aws-codestar-alpha/-/aws-codestar-alpha-2.41.0-alpha.0.tgz", + "integrity": "sha512-RIpFxuCYUv6k5uUO6b/9pk/YHpNfgDMEHh9HqEIQQYfAzEWpdUj8NGE0e3Q+/h9/jlbGzHhfxWZtxbS0r2w5cA==", + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "aws-cdk-lib": "^2.41.0", + "constructs": "^10.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, + "node_modules/@types/node": { + "version": "18.7.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz", + "integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/aws-cdk": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.41.0.tgz", + "integrity": "sha512-Ubko4X8VcbaLzcXvCQZPKBtgwBq033m5sSWtdrbdlDp7s2J4uWtY6KdO1uYKAvHyWjm7kGVmDyL1Wj1zx3TPUg==", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 14.15.0" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.41.0.tgz", + "integrity": "sha512-wh6lDaarzb8B+43TMxEBg+yHcXU9omlUGJz9zSdgjrmeQWBV8SD0jIvrERhDFvQLmRY4Vzy7FXxkI0mU+adDHQ==", + "bundleDependencies": [ + "@balena/dockerignore", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "yaml" + ], + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^9.1.0", + "ignore": "^5.2.0", + "jsonschema": "^1.4.1", + "minimatch": "^3.1.2", + "punycode": "^2.1.1", + "semver": "^7.3.7", + "yaml": "1.10.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "constructs": "^10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/at-least-node": { + "version": "1.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "1.1.11", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/concat-map": { + "version": "0.0.1", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "9.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.10", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lru-cache": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "3.1.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.3.7", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/check-more-types": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", + "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/constructs": { + "version": "10.1.107", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.1.107.tgz", + "integrity": "sha512-ewB60glRfBrzIlyujDJzIL/TWRhwwxtS579nOOqmvqaEKHtNgzHnDPBsq/vvvFpwZIRHs1ZUvtWPvxCg/2Ee6Q==", + "engines": { + "node": ">= 14.17.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", + "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.2.tgz", + "integrity": "sha512-JvpYKUmzQhYoIFgK2MOnF3bciIZoItIIoryihy0rIA+H4Jy0FmgyKYAHCTN98P5ybGSJcIFbh6QKeJdtZd1qhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/lazy-ass": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", + "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", + "dev": true, + "engines": { + "node": "> 0.8" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocked-env": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/mocked-env/-/mocked-env-1.3.5.tgz", + "integrity": "sha512-GyYY6ynVOdEoRlaGpaq8UYwdWkvrsU2xRme9B+WPSuJcNjh17+3QIxSYU6zwee0SbehhV6f06VZ4ahjG+9zdrA==", + "dev": true, + "dependencies": { + "check-more-types": "2.24.0", + "debug": "4.3.2", + "lazy-ass": "1.6.0", + "ramda": "0.27.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prettier": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/ramda": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", + "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", + "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + } + } +} diff --git a/cdk/package.json b/cdk/package.json new file mode 100644 index 0000000..67bc708 --- /dev/null +++ b/cdk/package.json @@ -0,0 +1,38 @@ +{ + "name": "cdk", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "build": "tsc", + "clean": "rimraf node_modules cdk.out dist", + "deploy": "cdk deploy --require-approval never --profile $ACCOUNT_ID", + "lint": "prettier --check src/**/*.ts", + "lint-fix": "prettier --write src/**/*.ts", + "setup-env-vars": "export DATA_OPS_PEERING_CONNECTION_ID=$DATA_OPS_PEERING_CONNECTION_ID && export DATA_OPS_CIDR_BLOCK=$DATA_OPS_CIDR_BLOCK", + "synth": "ts-node ./src/synth.ts", + "verify": "npm run build && npm run lint && npm run synth" + }, + "author": "", + "license": "ISC", + "dependencies": { + "@aws-cdk/aws-codestar-alpha": "2.41.0-alpha.0", + "aws-cdk": "2.41.0", + "aws-cdk-lib": "2.41.0", + "constructs": "10.1.107", + "dotenv": "16.0.2", + "prettier": "2.7.1", + "rimraf": "^3.0.2" + }, + "devDependencies": { + "@types/node": "18.7.18", + "mocked-env": "1.3.5", + "ts-node": "10.9.1", + "typescript": "4.8.3" + }, + "prettier": { + "bracketSameLine": true, + "singleQuote": true, + "trailingComma": "none" + } +} diff --git a/cdk/src/CloudflarePublicHostnameStack.ts b/cdk/src/CloudflarePublicHostnameStack.ts new file mode 100644 index 0000000..aa640c0 --- /dev/null +++ b/cdk/src/CloudflarePublicHostnameStack.ts @@ -0,0 +1,69 @@ +import { + App, + CfnOutput, + Duration, + Fn, + Stack, + StackProps, + aws_ec2, + aws_iam, + aws_kms, + aws_lambda +} from 'aws-cdk-lib'; +import { Effect } from 'aws-cdk-lib/aws-iam'; +import { Code, Runtime } from 'aws-cdk-lib/aws-lambda'; + +export default class CloudflarePublicHostnameStack extends Stack { + constructor(app: App, id: string, props: StackProps) { + super(app, id, props); + + const cloudflareLambda = new aws_lambda.Function(this, 'Function', { + code: Code.fromAsset(`${__dirname}/${process.env.ARTIFACT_PATH}`), + runtime: Runtime.JAVA_8, + functionName: 'cloudflare-public-hostname-lambda-Function-1KC7WIOVMTBR', + memorySize: 512, + timeout: Duration.seconds(60), + handler: 'com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler', + initialPolicy: [ + new aws_iam.PolicyStatement({ + effect: Effect.ALLOW, + actions: ['route53:GetHostedZone'], + resources: ['*'] + }) + ] + }); + + const keyAlias = 'alias/CloudflarePublicDnsRecordKey'; + + const kmsKey = new aws_kms.Key(this, 'Key', { + description: + 'Encryption key protecting secrets for the Cloudflare public record lambda', + enabled: true, + enableKeyRotation: true, + alias: keyAlias + }); + kmsKey.grant( + new aws_iam.ArnPrincipal( + Fn.sub('arn:aws:iam::${AWS::AccountId}:role/DataEncrypter') + ), + 'kms:Encrypt', + 'kms:ReEncrypt', + 'kms:DescribeKey' + ); + + kmsKey.grantDecrypt( + new aws_iam.ArnPrincipal(cloudflareLambda.role.roleArn) + ); + + new CfnOutput(this, 'CloudflarePublicHostnameLambda', { + description: 'ARN of the Lambda that interfaces with Cloudflare', + value: cloudflareLambda.functionName, + exportName: 'CloudflarePublicHostnameLambda' + }); + + new CfnOutput(this, 'CloudflarePublicHostnameKey', { + description: 'KMS Key Alias for Cloudflare public DNS record lambda', + value: keyAlias + }); + } +} diff --git a/cdk/src/index.ts b/cdk/src/index.ts new file mode 100644 index 0000000..8ab79dd --- /dev/null +++ b/cdk/src/index.ts @@ -0,0 +1,6 @@ +import { App } from 'aws-cdk-lib'; +import CloudflarePublicHostnameStack from './CloudflarePublicHostnameStack'; + +const app = new App(); + +new CloudflarePublicHostnameStack(app, 'cloudflare-public-hostname-lambda', {}); diff --git a/cdk/tsconfig.json b/cdk/tsconfig.json new file mode 100644 index 0000000..6892eb3 --- /dev/null +++ b/cdk/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "outDir": "dist", + "module": "commonJS", + "target": "ES6", + "typeRoots": [ + "./node_modules/@types" + ], + "types": [ + "node" + ] + }, + "exclude": [ + "cdk.out/**", + "cdk.json" + ] +} \ No newline at end of file diff --git a/project/CdkPlugin.scala b/project/CdkPlugin.scala new file mode 100644 index 0000000..6df8b33 --- /dev/null +++ b/project/CdkPlugin.scala @@ -0,0 +1,36 @@ +import com.typesafe.sbt.packager.universal.UniversalPlugin +import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport._ +import sbt.Keys.{baseDirectory, packageBin} +import sbt.internal.util.complete.DefaultParsers._ +import sbt.internal.util.complete.Parser +import sbt.{Def, settingKey, IO => _, _} + +object CdkDeployPlugin extends AutoPlugin { + object autoImport { + val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") + val deploy = taskKey[Int]("deploy to AWS") + } + + import autoImport._ + + override def trigger: PluginTrigger = NoTrigger + + override def requires: Plugins = UniversalPlugin + + override lazy val projectSettings = Seq( + cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, + deploy := { + import scala.sys.process._ + + val exitCode = Process( + cdkDeployCommand.value, + Option((ThisBuild / baseDirectory).value), + "ARTIFACT_PATH" -> (Universal / packageBin).value.toString, + ).! + + if (exitCode == 0) exitCode + else throw new IllegalStateException("cdk returned a non-zero exit code. Please check the logs for more information.") + } + ) + +} \ No newline at end of file diff --git a/project/build.properties b/project/build.properties index cabf73b..f344c14 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.2.7 +sbt.version = 1.8.2 diff --git a/stack/src/it/resources/logback-test.xml b/stack/src/it/resources/logback-test.xml deleted file mode 100644 index 684660e..0000000 --- a/stack/src/it/resources/logback-test.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - %date | log_thread=%thread | log_level=%-5level | log_logger=%logger | log_location=%class.%method | log_line=%line | log_message='%msg'%n - - - - - - - - - - diff --git a/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala b/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala deleted file mode 100644 index 3d59bd2..0000000 --- a/stack/src/it/scala/com/dwolla/cloudformation/cloudflare/StackIntegrationSpec.scala +++ /dev/null @@ -1,31 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.amazonaws.regions.Regions._ -import com.amazonaws.services.cloudformation.AmazonCloudFormationAsyncClientBuilder -import com.amazonaws.services.cloudformation.model.ValidateTemplateRequest -import com.dwolla.awssdk.utils.ScalaAsyncHandler.Implicits._ -import org.specs2.concurrent.ExecutionEnv -import org.specs2.mutable.{After, Specification} -import spray.json._ - -import scala.concurrent.duration._ - -class StackIntegrationSpec(implicit val ee: ExecutionEnv) extends Specification { - - trait Setup extends After { - val client = AmazonCloudFormationAsyncClientBuilder.standard().withRegion(US_WEST_2).build() - - override def after = client.shutdown() - } - - "Stack Template" should { - "validate using Amazon's online validation service" in new Setup { - - val request = new ValidateTemplateRequest().withTemplateBody(Stack.template().toJson.prettyPrint) - - val output = request.via(client.validateTemplateAsync) - - output.map(_.getDescription) must be_==("cloudflare-public-hostname-lambda lambda function and supporting resources").await(0, 10.seconds) - } - } -} diff --git a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala b/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala deleted file mode 100644 index 5b477dd..0000000 --- a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/CreateTemplate.scala +++ /dev/null @@ -1,13 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import java.nio.charset.StandardCharsets -import java.nio.file.{Files, Paths} - -import spray.json._ - -object CreateTemplate extends App { - val template = Stack.template() - - private val outputFilename = args(0) - Files.write(Paths.get(outputFilename), template.toJson.prettyPrint.getBytes(StandardCharsets.UTF_8)) -} diff --git a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala b/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala deleted file mode 100644 index 9143050..0000000 --- a/stack/src/main/scala/com/dwolla/cloudformation/cloudflare/Stack.scala +++ /dev/null @@ -1,137 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler -import com.monsanto.arch.cloudformation.model._ -import com.monsanto.arch.cloudformation.model.resource._ - -object Stack { - def template(): Template = { - val role = `AWS::IAM::Role`("Role", - AssumeRolePolicyDocument = PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("Service" → Seq("lambda.amazonaws.com")))), - Action = Seq("sts:AssumeRole") - ) - )), - Policies = Option(Seq( - Policy("Policy", - PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Action = Seq( - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ), - Resource = Option("arn:aws:logs:*:*:*") - ), - PolicyStatement( - Effect = "Allow", - Action = Seq( - "route53:GetHostedZone" - ), - Resource = Option("*") - ) - )) - ) - )) - ) - - val s3Bucket = StringParameter("S3Bucket", "bucket where Lambda code can be found") - val s3Key = StringParameter("S3Key", "key where Lambda code can be found") - - val key = `AWS::KMS::Key`("Key", - Option("Encryption key protecting secrets for the Cloudflare public record lambda"), - Enabled = Option(true), - EnableKeyRotation = Option(true), - KeyPolicy = PolicyDocument( - Seq( - PolicyStatement( - Sid = Option("AllowDataEncrypterToEncrypt"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::Sub`("arn:aws:iam::${AWS::AccountId}:role/DataEncrypter"))))), - Action = Seq( - "kms:Encrypt", - "kms:ReEncrypt", - "kms:DescribeKey" - ), - Resource = Option("*") - ), - PolicyStatement( - Sid = Option("AllowLambdaToDecrypt"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::GetAtt`(Seq(role.name, "Arn")))))), - Action = Seq( - "kms:Decrypt", - "kms:DescribeKey" - ), - Resource = Option("*") - ), - PolicyStatement( - Sid = Option("CloudFormationDeploymentRoleOwnsKey"), - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("AWS" → Seq(`Fn::Sub`("arn:aws:iam::${AWS::AccountId}:role/cloudformation/deployer/cloudformation-deployer"))))), - Action = Seq( - "kms:Create*", - "kms:Describe*", - "kms:Enable*", - "kms:List*", - "kms:Put*", - "kms:Update*", - "kms:Revoke*", - "kms:Disable*", - "kms:Get*", - "kms:Delete*", - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion" - ), - Resource = Option("*") - ) - ) - ) - ) - - val alias = `AWS::KMS::Alias`("KeyAlias", AliasName = "alias/CloudflarePublicDnsRecordKey", TargetKeyId = ResourceRef(key)) - - val lambda = `AWS::Lambda::Function`("Function", - Code = Code( - S3Bucket = Option(s3Bucket), - S3Key = Option(s3Key), - S3ObjectVersion = None, - ZipFile = None - ), - Description = Option("Creates or updates a public hostname at Cloudflare zone"), - Handler = classOf[CloudflareDnsRecordHandler].getName, - Runtime = Java8, - MemorySize = Some(512), - Role = `Fn::GetAtt`(Seq(role.name, "Arn")), - Timeout = Option(60) - ) - - Template( - Description = "cloudflare-public-hostname-lambda lambda function and supporting resources", - Parameters = Option(Seq( - s3Bucket, - s3Key - )), - Resources = Option(Seq(role, lambda, key, alias)), - Conditions = None, - Mappings = None, - Routables = None, - Outputs = Some(Seq( - Output( - "CloudflarePublicHostnameLambda", - "ARN of the Lambda that interfaces with Cloudflare", - `Fn::GetAtt`(Seq(lambda.name, "Arn")), - Some("CloudflarePublicHostnameLambda") - ), - Output( - "CloudflarePublicHostnameKey", - "KMS Key Alias for Cloudflare public DNS record lambda", - ResourceRef(alias) - ) - )) - ) - } -} diff --git a/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java b/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java deleted file mode 100644 index ade187f..0000000 --- a/stack/src/test/java/com/dwolla/cloudformation/cloudflare/ConstructorTest.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.dwolla.cloudformation.cloudflare; - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler; - -public class ConstructorTest { - - // This needs to compile for the Lambda to be constructable at AWS - final CloudflareDnsRecordHandler handler = new CloudflareDnsRecordHandler(); - -} diff --git a/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala b/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala deleted file mode 100644 index 83d0172..0000000 --- a/stack/src/test/scala/com/dwolla/cloudformation/cloudflare/StackSpec.scala +++ /dev/null @@ -1,96 +0,0 @@ -package com.dwolla.cloudformation.cloudflare - -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler -import com.monsanto.arch.cloudformation.model._ -import com.monsanto.arch.cloudformation.model.resource._ -import org.specs2.matcher.ContainWithResult -import org.specs2.mutable.Specification -import org.specs2.specification.Scope - -class StackSpec extends Specification { - - trait Setup extends Scope { - val template = Stack.template() - - val s3Bucket = StringParameter("S3Bucket", "bucket where Lambda code can be found") - val s3Key = StringParameter("S3Key", "key where Lambda code can be found") - - val role = `AWS::IAM::Role`("Role", - AssumeRolePolicyDocument = PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Principal = Option(DefinedPrincipal(Map("Service" → Seq("lambda.amazonaws.com")))), - Action = Seq("sts:AssumeRole") - ) - )), - Policies = Option(Seq( - Policy("Policy", - PolicyDocument(Seq( - PolicyStatement( - Effect = "Allow", - Action = Seq( - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ), - Resource = Option("arn:aws:logs:*:*:*") - ), - PolicyStatement( - Effect = "Allow", - Action = Seq( - "route53:GetHostedZone" - ), - Resource = Option("*") - ) - )) - ) - )) - ) - - val function = `AWS::Lambda::Function`("Function", - Code = Code( - S3Bucket = Option(s3Bucket), - S3Key = Option(s3Key), - S3ObjectVersion = None, - ZipFile = None - ), - Description = Option("Creates or updates a public hostname at Cloudflare zone"), - Handler = classOf[CloudflareDnsRecordHandler].getName, - Runtime = Java8, - MemorySize = Some(512), - Role = `Fn::GetAtt`(Seq(role.name, "Arn")), - Timeout = Option(60) - ) - } - - "Template" should { - - "define lambda function for correct handler class" in new Setup { - template.lookupResource[`AWS::Lambda::Function`]("Function") must_== function - } - - "define IAM role for the function" in new Setup { - template.lookupResource[`AWS::IAM::Role`]("Role") must_== role - } - - "define input parameters for S3 location so that info can come from sbt" in new Setup { - template.Parameters must beSome(contain(s3Bucket.asInstanceOf[Parameter])) - template.Parameters must beSome(contain(s3Key.asInstanceOf[Parameter])) - } - - "have an appropriate description" in new Setup { - template.Description must_== "cloudflare-public-hostname-lambda lambda function and supporting resources" - } - - "export the lambda function" in new Setup { - template.Outputs must beSome(thingThatContains(Output( - "CloudflarePublicHostnameLambda", - "ARN of the Lambda that interfaces with Cloudflare", - `Fn::GetAtt`(Seq("Function", "Arn")), - Some("CloudflarePublicHostnameLambda") - ))) - } - } - - def thingThatContains[R](output: Output[R]): ContainWithResult[Output[_]] = contain(output) -} From c32fe4fa4929dcee85b42b4b318a8f643a6de70f Mon Sep 17 00:00:00 2001 From: lgorski Date: Fri, 14 Apr 2023 11:44:32 -0500 Subject: [PATCH 02/11] begin resolving bintray dependencies WIP --- build.sbt | 54 +++++---------------------------------------- project/plugins.sbt | 13 +++-------- 2 files changed, 9 insertions(+), 58 deletions(-) diff --git a/build.sbt b/build.sbt index 7c902de..8018253 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ lazy val commonSettings = Seq( organization := "Dwolla", - homepage := Option(url("https://stash.dwolla.net/projects/OPS/repos/cloudflare-public-hostname-lambda")), + homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")), ) lazy val specs2Version = "4.3.0" @@ -9,24 +9,21 @@ lazy val awsSdkVersion = "1.11.475" lazy val `cloudflare-public-hostname-lambda` = (project in file(".")) .settings( name := "cloudflare-public-hostname-lambda", - resolvers ++= Seq( - Resolver.bintrayRepo("dwolla", "maven") - ), libraryDependencies ++= { - val fs2AwsVersion = "1.3.0" + val fs2AwsVersion = "2.0.0-M16" Seq( - "com.dwolla" %% "scala-cloudformation-custom-resource" % "2.1.0", + "com.dwolla" %% "scala-cloudformation-custom-resource" % "4.0.0-M3", "com.dwolla" %% "fs2-aws" % fs2AwsVersion, "io.circe" %% "circe-fs2" % "0.9.0", - "com.dwolla" %% "cloudflare-api-client" % "4.0.0-M4", + "com.dwolla" %% "cloudflare-api-client" % "4.0.0-M15", "org.http4s" %% "http4s-blaze-client" % "0.18.21", "com.amazonaws" % "aws-java-sdk-kms" % awsSdkVersion, "org.apache.httpcomponents" % "httpclient" % "4.5.2", "org.specs2" %% "specs2-core" % specs2Version % Test, "org.specs2" %% "specs2-mock" % specs2Version % Test, "org.specs2" %% "specs2-matcher-extra" % specs2Version % Test, - "com.dwolla" %% "testutils-specs2" % "1.11.0" % Test exclude("ch.qos.logback", "logback-classic"), + "com.dwolla" %% "testutils-specs2" % "2.0.0-M6" % Test exclude("ch.qos.logback", "logback-classic"), "com.dwolla" %% "fs2-aws-testkit" % fs2AwsVersion % Test, ) }, @@ -35,43 +32,4 @@ lazy val `cloudflare-public-hostname-lambda` = (project in file(".")) .settings(commonSettings: _*) .configs(IntegrationTest) .settings(Defaults.itSettings: _*) - .enablePlugins(PublishToS3) - -lazy val stack: Project = (project in file("stack")) - .settings(commonSettings: _*) - .settings( - resolvers ++= Seq(Resolver.jcenterRepo), - libraryDependencies ++= { - val scalaAwsUtilsVersion = "1.6.1" - - Seq( - "com.monsanto.arch" %% "cloud-formation-template-generator" % "3.5.4", - "org.specs2" %% "specs2-core" % specs2Version % "test,it", - "com.amazonaws" % "aws-java-sdk-cloudformation" % awsSdkVersion % IntegrationTest, - "com.dwolla" %% "scala-aws-utils" % scalaAwsUtilsVersion % IntegrationTest, - ) - }, - stackName := (name in `cloudflare-public-hostname-lambda`).value, - stackParameters := List( - "S3Bucket" → (s3Bucket in `cloudflare-public-hostname-lambda`).value, - "S3Key" → (s3Key in `cloudflare-public-hostname-lambda`).value - ), - awsAccountId := sys.props.get("AWS_ACCOUNT_ID"), - awsRoleName := Option("cloudformation/deployer/cloudformation-deployer"), - scalacOptions --= Seq( - "-Xlint:missing-interpolator", - "-Xlint:option-implicit", - ), - ) - .configs(IntegrationTest) - .settings(Defaults.itSettings: _*) - .enablePlugins(CloudFormationStack) - .dependsOn(`cloudflare-public-hostname-lambda`) - -assemblyMergeStrategy in assembly := { - case PathList(ps @ _*) if ps.last == "Log4j2Plugins.dat" => sbtassembly.Log4j2MergeStrategy.plugincache - case PathList("META-INF", "MANIFEST.MF") => MergeStrategy.discard - case PathList("log4j2.xml") => MergeStrategy.singleOrError - case _ ⇒ MergeStrategy.first -} -test in assembly := {} + .enablePlugins(UniversalPlugin, JavaAppPackaging) diff --git a/project/plugins.sbt b/project/plugins.sbt index c65751e..ee7fdc3 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,10 +1,3 @@ -addSbtPlugin("com.dwolla.sbt" %% "sbt-s3-publisher" % "1.2.0") -addSbtPlugin("com.dwolla.sbt" %% "sbt-cloudformation-stack" % "1.2.2") -addSbtPlugin("com.dwolla.sbt" %% "sbt-dwolla-base" % "1.2.0") -addSbtPlugin("com.dwijnand" % "sbt-travisci" % "1.1.1") -addSbtPlugin("com.dwolla" % "sbt-assembly-log4j2" % "1.0.0-0e5d5dd98c4c1e12ff7134536456679069c13e4d") - -resolvers ++= Seq( - Resolver.bintrayIvyRepo("dwolla", "sbt-plugins"), - Resolver.bintrayRepo("dwolla", "maven") -) +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.2") +addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.14.2") +addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.9") \ No newline at end of file From c4b22bd02ac0b590cfdba00a17a2023682b06da2 Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Thu, 20 Apr 2023 12:57:08 -0500 Subject: [PATCH 03/11] replace deprecated Unicode arrows with ASCII equivalents --- .../record/CloudflareDnsRecordHandler.scala | 68 +++++++-------- .../lambda/cloudflare/record/package.scala | 14 ++-- .../CloudflareDnsRecordHandlerSpec.scala | 84 +++++++++---------- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index af8991e..1dde7b7 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -30,27 +30,27 @@ class CloudflareDnsRecordHandler(httpClientStream: Stream[IO, Client[IO]], kmsCl private def constructCloudflareClient(resourceProperties: Map[String, Json]): Stream[IO, DnsRecordClient[IO]] = for { - (email, key) ← decryptSensitiveProperties(resourceProperties) - httpClient ← httpClientStream + (email, key) <- decryptSensitiveProperties(resourceProperties) + httpClient <- httpClientStream executor = new StreamingCloudflareApiExecutor[IO](httpClient, CloudflareAuthorization(email, key)) } yield DnsRecordClient(executor) private def decryptSensitiveProperties(resourceProperties: Map[String, Json]): Stream[IO, (String, String)] = for { - kmsClient ← kmsClientStream - emailCryptoText ← Stream.emit(resourceProperties("CloudflareEmail")).covary[IO].through(decoder[IO, String]) - keyCryptoText ← Stream.emit(resourceProperties("CloudflareKey")).covary[IO].through(decoder[IO, String]) - plaintextMap ← kmsClient.decryptBase64("CloudflareEmail" → emailCryptoText, "CloudflareKey" → keyCryptoText).map(_.mapValues(new String(_, "UTF-8"))) + kmsClient <- kmsClientStream + emailCryptoText <- Stream.emit(resourceProperties("CloudflareEmail")).covary[IO].through(decoder[IO, String]) + keyCryptoText <- Stream.emit(resourceProperties("CloudflareKey")).covary[IO].through(decoder[IO, String]) + plaintextMap <- kmsClient.decryptBase64("CloudflareEmail" -> emailCryptoText, "CloudflareKey" -> keyCryptoText).map(_.mapValues(new String(_, "UTF-8"))) emailPlaintext = plaintextMap("CloudflareEmail") keyPlaintext = plaintextMap("CloudflareKey") } yield (emailPlaintext, keyPlaintext) override def handleRequest(input: CloudFormationCustomResourceRequest): IO[HandlerResponse] = (for { - resourceProperties ← Stream.eval(IO.fromEither(input.ResourceProperties.toRight(MissingResourceProperties))) - dnsRecord ← parseRecordFrom(resourceProperties) - cloudflareClient ← constructCloudflareClient(resourceProperties) - res ← UpdateCloudflare(cloudflareClient)(input.RequestType, dnsRecord, input.PhysicalResourceId) + resourceProperties <- Stream.eval(IO.fromEither(input.ResourceProperties.toRight(MissingResourceProperties))) + dnsRecord <- parseRecordFrom(resourceProperties) + cloudflareClient <- constructCloudflareClient(resourceProperties) + res <- UpdateCloudflare(cloudflareClient)(input.RequestType, dnsRecord, input.PhysicalResourceId) } yield res).compile.toList.map(_.head) } @@ -84,26 +84,26 @@ object UpdateCloudflare { unidentifiedDnsRecord: UnidentifiedDnsRecord, physicalResourceId: Option[String]): Stream[IO, HandlerResponse] = requestType.toUpperCase match { - case "CREATE" | "UPDATE" ⇒ + case "CREATE" | "UPDATE" => handleCreateOrUpdate(unidentifiedDnsRecord, physicalResourceId)(cloudflareDnsRecordClient) - case "DELETE" ⇒ handleDelete(physicalResourceId.get)(cloudflareDnsRecordClient) + case "DELETE" => handleDelete(physicalResourceId.get)(cloudflareDnsRecordClient) } private def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = unidentifiedDnsRecord.recordType.toUpperCase() match { - case "CNAME" ⇒ Stream.eval(handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId)) - case _ ⇒ handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) + case "CNAME" => Stream.eval(handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId)) + case _ => handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) } private def handleDelete(physicalResourceId: String) (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { for { - existingRecord ← cloudflare.getByUri(physicalResourceId) - deleted ← cloudflare.deleteDnsRecord(existingRecord.physicalResourceId) + existingRecord <- cloudflare.getByUri(physicalResourceId) + deleted <- cloudflare.deleteDnsRecord(existingRecord.physicalResourceId) } yield { val data = Map( - "deletedRecordId" → Json.fromString(deleted) + "deletedRecordId" -> Json.fromString(deleted) ) HandlerResponse(physicalResourceId, data) @@ -112,21 +112,21 @@ object UpdateCloudflare { private def warnAboutMissingRecordDeletion(physicalResourceId: String): IO[HandlerResponse] = for { - _ ← IO(logger.warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!")) + _ <- IO(logger.warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!")) } yield HandlerResponse(physicalResourceId, Map.empty[String, Json]) private def handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Stream[IO, String]) (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { for { - maybeExistingRecord ← cloudformationProvidedPhysicalResourceId.flatMap(cloudflare.getByUri).last - createOrUpdate ← maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) + maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.flatMap(cloudflare.getByUri).last + createOrUpdate <- maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeExistingRecord) } private def findAtMostOneExistingCNAME(name: String) (implicit cloudflare: DnsRecordClient[IO]): IO[Option[IdentifiedDnsRecord]] = cloudflare.getExistingDnsRecords(name, recordType = Option("CNAME")).compile.toList - .flatMap { identifiedDnsRecords ⇒ + .flatMap { identifiedDnsRecords => if (identifiedDnsRecords.size < 2) IO.pure(identifiedDnsRecords.headOption) else IO.raiseError(MultipleCloudflareRecordsExistForDomainNameException(name, identifiedDnsRecords.map { import com.dwolla.cloudflare.domain.model.Implicits._ @@ -137,10 +137,10 @@ object UpdateCloudflare { private def handleCreateOrUpdateCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) (implicit cloudflare: DnsRecordClient[IO]): IO[HandlerResponse] = for { - maybeIdentifiedDnsRecord ← findAtMostOneExistingCNAME(unidentifiedDnsRecord.name) - createOrUpdate ← maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord).compile.toList.map(_.head) - _ ← warnIfProvidedIdDoesNotMatchDiscoveredId(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord, unidentifiedDnsRecord.name) - _ ← warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) + maybeIdentifiedDnsRecord <- findAtMostOneExistingCNAME(unidentifiedDnsRecord.name) + createOrUpdate <- maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord).compile.toList.map(_.head) + _ <- warnIfProvidedIdDoesNotMatchDiscoveredId(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord, unidentifiedDnsRecord.name) + _ <- warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeIdentifiedDnsRecord) /*_*/ @@ -157,15 +157,15 @@ object UpdateCloudflare { private def updateRecord(existingRecord: IdentifiedDnsRecord)(implicit cloudflare: DnsRecordClient[IO]): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = for { - update ← assertRecordTypeWillNotChange(existingRecord.recordType).andThen { unidentifiedDnsRecord ⇒ + update <- assertRecordTypeWillNotChange(existingRecord.recordType).andThen { unidentifiedDnsRecord => cloudflare.updateDnsRecord(unidentifiedDnsRecord.identifyAs(existingRecord.physicalResourceId)).map(Update(_)) } } yield update private def warnIfProvidedIdDoesNotMatchDiscoveredId(physicalResourceId: Option[String], updateableRecord: Option[IdentifiedDnsRecord], hostname: String): IO[Unit] = IO { for { - providedId ← physicalResourceId - discoveredId ← updateableRecord.map(_.physicalResourceId) + providedId <- physicalResourceId + discoveredId <- updateableRecord.map(_.physicalResourceId) if providedId != discoveredId } logger.warn(s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""") } @@ -173,18 +173,18 @@ object UpdateCloudflare { private def warnIfNoIdWasProvidedButDnsRecordExisted(physicalResourceId: Option[String], existingRecord: Option[IdentifiedDnsRecord]): IO[Unit] = IO { if (physicalResourceId.isEmpty) for { - dnsRecord ← existingRecord - discoveredId ← dnsRecord.physicalResourceId + dnsRecord <- existingRecord + discoveredId <- dnsRecord.physicalResourceId } logger.warn(s"""Discovered DNS record ID "$discoveredId" for hostname "${dnsRecord.name}", with existing content "${dnsRecord.content}". This record will be updated instead of creating a new record.""") } private def createOrUpdateToHandlerResponse(createOrUpdate: CreateOrUpdate[IdentifiedDnsRecord], existingRecord: Option[IdentifiedDnsRecord]): HandlerResponse = { val dnsRecord = createOrUpdate.value val data = Map( - "dnsRecord" → dnsRecord.asJson, - "created" → createOrUpdate.create.asJson, - "updated" → createOrUpdate.update.asJson, - "oldDnsRecord" → existingRecord.asJson, + "dnsRecord" -> dnsRecord.asJson, + "created" -> createOrUpdate.create.asJson, + "updated" -> createOrUpdate.update.asJson, + "oldDnsRecord" -> existingRecord.asJson, ) HandlerResponse(dnsRecord.physicalResourceId, data) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala index 6cd4931..4f635f7 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala @@ -8,13 +8,13 @@ import cats.syntax.contravariant._ package object record { implicit def TaggedStringEncoder[B]: Encoder[String @@ B] = Encoder[String].narrow - implicit val decodeUnidentifiedDnsRecord: Decoder[UnidentifiedDnsRecord] = (c: HCursor) ⇒ + implicit val decodeUnidentifiedDnsRecord: Decoder[UnidentifiedDnsRecord] = (c: HCursor) => for { - name ← c.downField("Name").as[String] - content ← c.downField("Content").as[String] - recordType ← c.downField("Type").as[String] - ttl ← c.downField("TTL").as[Option[Int]] - proxied ← c.downField("Proxied").as[Option[String]].map(_.flatMap(str ⇒ try { Some(str.toBoolean) } catch { case _: IllegalArgumentException ⇒ None })) - priority ← c.downField("Priority").as[Option[Int]] + name <- c.downField("Name").as[String] + content <- c.downField("Content").as[String] + recordType <- c.downField("Type").as[String] + ttl <- c.downField("TTL").as[Option[Int]] + proxied <- c.downField("Proxied").as[Option[String]].map(_.flatMap(str => try { Some(str.toBoolean) } catch { case _: IllegalArgumentException => None })) + priority <- c.downField("Priority").as[Option[Int]] } yield UnidentifiedDnsRecord(name, content, recordType, ttl, proxied, priority) } diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala index 578d1dd..6610c09 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala @@ -34,19 +34,19 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with requestType = "update", physicalResourceId = Option("different-physical-id"), resourceProperties = Option(Map( - "Name" → Json.fromString("example.dwolla.com"), - "Content" → Json.fromString("new-example.dwollalabs.com"), - "Type" → Json.fromString("CNAME"), - "TTL" → Json.fromString("42"), - "Proxied" → Json.fromString("true"), - "CloudflareEmail" → Json.fromString("cloudflare-account-email@dwollalabs.com"), - "CloudflareKey" → Json.fromString("fake-key") + "Name" -> Json.fromString("example.dwolla.com"), + "Content" -> Json.fromString("new-example.dwollalabs.com"), + "Type" -> Json.fromString("CNAME"), + "TTL" -> Json.fromString("42"), + "Proxied" -> Json.fromString("true"), + "CloudflareEmail" -> Json.fromString("cloudflare-account-email@dwollalabs.com"), + "CloudflareKey" -> Json.fromString("fake-key") )) ) val output = handler.handleRequest(request).unsafeToFuture() - output must throwA[AWSKMSException].like { case ex ⇒ ex.getMessage must startWith(kmsExceptionMessage) }.await + output must throwA[AWSKMSException].like { case ex => ex.getMessage must startWith(kmsExceptionMessage) }.await } } @@ -86,12 +86,12 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("created" → expectedRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("created" -> expectedRecord.asJson) + handlerResponse.data must havePair("updated" -> None.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) }.await } @@ -177,10 +177,10 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, providedPhysicalId) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) }.await } @@ -219,12 +219,12 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("created" → expectedRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("created" -> expectedRecord.asJson) + handlerResponse.data must havePair("updated" -> None.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) }.await } @@ -270,12 +270,12 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== existingRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → existingRecord.asJson) - handlerResponse.data must havePair("created" → existingRecord.asJson) - handlerResponse.data must havePair("updated" → None.asJson) - handlerResponse.data must havePair("oldDnsRecord" → None.asJson) + handlerResponse.data must havePair("dnsRecord" -> existingRecord.asJson) + handlerResponse.data must havePair("created" -> existingRecord.asJson) + handlerResponse.data must havePair("updated" -> None.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) }.await } } @@ -319,10 +319,10 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) }.await // TODO deal with logging @@ -366,10 +366,10 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, Option(physicalResourceId)) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) }.await // there was one(mockLogger).warn(startsWith("""Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com"""")) @@ -405,10 +405,10 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" → expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" → existingRecord.asJson) + handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) + handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) }.await // TODO deal with logging @@ -442,7 +442,7 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) ⇒ + case DnsRecordTypeChange(existingRecordType, newRecordType) => existingRecordType must_== "A" newRecordType must_== "CNAME" } @@ -471,7 +471,7 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) ⇒ + case DnsRecordTypeChange(existingRecordType, newRecordType) => existingRecordType must_== "MX" newRecordType must_== "TXT" } @@ -531,9 +531,9 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must havePair("deletedRecordId" → physicalResourceId.asJson) + handlerResponse.data must havePair("deletedRecordId" -> physicalResourceId.asJson) }.await } @@ -558,9 +558,9 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) ⇒ + case List(handlerResponse) => handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must not(havePair("deletedRecordId" → physicalResourceId)) + handlerResponse.data must not(havePair("deletedRecordId" -> physicalResourceId)) }.await // TODO deal with logging @@ -596,7 +596,7 @@ class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with "DnsRecordTypeChange" should { "mention the existing and new record types" >> { DnsRecordTypeChange("existing", "new") must beLikeA[RuntimeException] { - case ex ⇒ ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" + case ex => ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" } } } From d6ac810a8268a1a3c7ebfa353754fbcfdc882f7f Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Tue, 23 Sep 2025 12:00:14 -0500 Subject: [PATCH 04/11] wip before splitting SchemaVisitorCirceCodec into SchemaVisitorCirceEncoder, SchemaVisitorCirceDecoder, and SchemaVisitorCirceKeyEncoder --- .travis.yml | 14 - build.sbt | 47 ++- project/CdkPlugin.scala | 71 ++-- project/build.properties | 2 +- project/plugins.sbt | 8 +- .../record/CloudflareDnsRecordHandler.scala | 331 ++++++++++-------- .../cloudflare/record/CreateOrUpdate.scala | 44 +++ .../record/SchemaVisitorCirceCodec.scala | 90 +++++ .../scala/io/circe/JsoniterScalaCodec.scala | 44 +++ src/main/smithy/KMS.smithy | 7 + 10 files changed, 442 insertions(+), 216 deletions(-) delete mode 100644 .travis.yml create mode 100644 src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala create mode 100644 src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala create mode 100644 src/main/scala/io/circe/JsoniterScalaCodec.scala create mode 100644 src/main/smithy/KMS.smithy diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d391b0c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: scala - -scala: - - 2.12.7 - -env: - global: - - JDK=oraclejdk8 - - AWS_REGION=us-west-2 - -before_script: - - jdk_switcher use $JDK - -script: sbt ++$TRAVIS_SCALA_VERSION clean stack/clean 'testOnly -- timefactor 10' 'stack/testOnly -- timefactor 10' diff --git a/build.sbt b/build.sbt index 8018253..daf0ab9 100644 --- a/build.sbt +++ b/build.sbt @@ -1,35 +1,32 @@ -lazy val commonSettings = Seq( - organization := "Dwolla", - homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")), -) +evictionErrorLevel := Level.Warn -lazy val specs2Version = "4.3.0" -lazy val awsSdkVersion = "1.11.475" +ThisBuild / organization := "Dwolla" +ThisBuild / homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")) +ThisBuild / scalaVersion := "2.13.16" -lazy val `cloudflare-public-hostname-lambda` = (project in file(".")) +lazy val `cloudflare-public-hostname-lambda` = project + .in(file(".")) .settings( name := "cloudflare-public-hostname-lambda", + smithy4sAwsSpecs ++= Seq(AWS.kms), + scalacOptions += "-Wconf:src=src_managed/.*:s", libraryDependencies ++= { - val fs2AwsVersion = "2.0.0-M16" - Seq( - "com.dwolla" %% "scala-cloudformation-custom-resource" % "4.0.0-M3", - "com.dwolla" %% "fs2-aws" % fs2AwsVersion, - "io.circe" %% "circe-fs2" % "0.9.0", - "com.dwolla" %% "cloudflare-api-client" % "4.0.0-M15", - "org.http4s" %% "http4s-blaze-client" % "0.18.21", - "com.amazonaws" % "aws-java-sdk-kms" % awsSdkVersion, - "org.apache.httpcomponents" % "httpclient" % "4.5.2", - "org.specs2" %% "specs2-core" % specs2Version % Test, - "org.specs2" %% "specs2-mock" % specs2Version % Test, - "org.specs2" %% "specs2-matcher-extra" % specs2Version % Test, - "com.dwolla" %% "testutils-specs2" % "2.0.0-M6" % Test exclude("ch.qos.logback", "logback-classic"), - "com.dwolla" %% "fs2-aws-testkit" % fs2AwsVersion % Test, + "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.3.1", + "com.dwolla" %%% "cloudflare-api-client" % "4.0.0-M16", + "com.disneystreaming.smithy4s" %%% "smithy4s-http4s" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %%% "smithy4s-aws-http4s" % smithy4sVersion.value, + "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value, + "org.http4s" %%% "http4s-ember-client" % "0.23.30", + "org.typelevel" %%% "mouse" % "1.3.2", + "org.tpolecat" %%% "natchez-mtl" % "0.3.8", + "org.tpolecat" %%% "natchez-xray" % "0.3.8", + "org.tpolecat" %%% "natchez-http4s" % "0.6.1", + "org.typelevel" %%% "log4cats-core" % "2.7.1", + "org.typelevel" %%% "log4cats-js-console" % "2.7.1", + "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-circe" % "2.38.0", ) }, updateOptions := updateOptions.value.withCachedResolution(false), ) - .settings(commonSettings: _*) - .configs(IntegrationTest) - .settings(Defaults.itSettings: _*) - .enablePlugins(UniversalPlugin, JavaAppPackaging) + .enablePlugins(Smithy4sCodegenPlugin, LambdaJSPlugin) diff --git a/project/CdkPlugin.scala b/project/CdkPlugin.scala index 6df8b33..7292a32 100644 --- a/project/CdkPlugin.scala +++ b/project/CdkPlugin.scala @@ -1,36 +1,35 @@ -import com.typesafe.sbt.packager.universal.UniversalPlugin -import com.typesafe.sbt.packager.universal.UniversalPlugin.autoImport._ -import sbt.Keys.{baseDirectory, packageBin} -import sbt.internal.util.complete.DefaultParsers._ -import sbt.internal.util.complete.Parser -import sbt.{Def, settingKey, IO => _, _} - -object CdkDeployPlugin extends AutoPlugin { - object autoImport { - val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") - val deploy = taskKey[Int]("deploy to AWS") - } - - import autoImport._ - - override def trigger: PluginTrigger = NoTrigger - - override def requires: Plugins = UniversalPlugin - - override lazy val projectSettings = Seq( - cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, - deploy := { - import scala.sys.process._ - - val exitCode = Process( - cdkDeployCommand.value, - Option((ThisBuild / baseDirectory).value), - "ARTIFACT_PATH" -> (Universal / packageBin).value.toString, - ).! - - if (exitCode == 0) exitCode - else throw new IllegalStateException("cdk returned a non-zero exit code. Please check the logs for more information.") - } - ) - -} \ No newline at end of file +// TODO +//import sbt.Keys.{baseDirectory, packageBin} +//import sbt.internal.util.complete.DefaultParsers._ +//import sbt.internal.util.complete.Parser +//import sbt.{Def, settingKey, IO => _, _} +// +//object CdkDeployPlugin extends AutoPlugin { +// object autoImport { +// val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") +// val deploy = taskKey[Int]("deploy to AWS") +// } +// +// import autoImport._ +// +// override def trigger: PluginTrigger = NoTrigger +// +// override def requires: Plugins = UniversalPlugin +// +// override lazy val projectSettings = Seq( +// cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, +// deploy := { +// import scala.sys.process._ +// +// val exitCode = Process( +// cdkDeployCommand.value, +// Option((ThisBuild / baseDirectory).value), +// "ARTIFACT_PATH" -> (Universal / packageBin).value.toString, +// ).! +// +// if (exitCode == 0) exitCode +// else throw new IllegalStateException("cdk returned a non-zero exit code. Please check the logs for more information.") +// } +// ) +// +//} diff --git a/project/build.properties b/project/build.properties index f344c14..5e6884d 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version = 1.8.2 +sbt.version=1.11.6 diff --git a/project/plugins.sbt b/project/plugins.sbt index ee7fdc3..67398e0 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,5 @@ -addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.2") -addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.14.2") -addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.9") \ No newline at end of file +addSbtPlugin("org.typelevel" % "sbt-typelevel-settings" % "0.8.0") +addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") +addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % "0.3.1") // in plugins.sbt +addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.42") diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index 1dde7b7..de03615 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -1,205 +1,262 @@ package com.dwolla.lambda.cloudflare.record -import _root_.fs2._ -import _root_.io.circe._ -import _root_.io.circe.fs2._ -import _root_.io.circe.generic.auto._ -import _root_.io.circe.syntax._ -import cats.data._ -import cats.effect._ -import cats.implicits._ -import com.dwolla.cloudflare._ +import _root_.io.circe.* +import _root_.io.circe.generic.auto.* +import _root_.io.circe.syntax.* +import cats.* +import cats.data.* +import cats.effect.std.Env +import cats.effect.{Trace as _, *} +import cats.mtl.Local +import cats.syntax.all.* +import com.amazonaws.kms.{CiphertextType, KMS, PlaintextType} +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model.* import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists -import com.dwolla.cloudflare.domain.model._ -import com.dwolla.fs2aws.kms.KmsDecrypter -import com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler.parseRecordFrom -import com.dwolla.lambda.cloudformation._ +import feral.lambda.cloudformation.{CloudFormationCustomResource, CloudFormationCustomResourceRequest, HandlerResponse} +import feral.lambda.{IOLambda, Invocation, KernelSource, TracedHandler, cloudformation} +import fs2.io.net.Network +import mouse.all.* +import natchez.* +import natchez.http4s.* +import natchez.mtl.* +import natchez.xray.XRay import org.http4s.Headers -import org.http4s.client.Client -import org.http4s.client.blaze._ -import org.http4s.client.middleware.Logger -import org.http4s.syntax.string._ +import org.http4s.client.{Client, middleware} +import org.http4s.ember.client.EmberClientBuilder +import org.typelevel.ci.* +import org.typelevel.log4cats.console.* +import org.typelevel.log4cats.{Logger, LoggerFactory} +import smithy4s.aws.kernel.AwsRegion +import smithy4s.aws.{AwsClient, AwsEnvironment} +import _root_.io.circe.JsoniterScalaCodec.* +import smithy4s.json.Json.* -import scala.concurrent.ExecutionContext.Implicits.global +import scala.util.control.NoStackTrace -class CloudflareDnsRecordHandler(httpClientStream: Stream[IO, Client[IO]], kmsClientStream: Stream[IO, KmsDecrypter[IO]]) extends CatsAbstractCustomResourceHandler[IO] { - def this() = this( - Http1Client.stream[IO]().map(Logger(logHeaders = true, logBody = true, (Headers.SensitiveHeaders + "X-Auth-Key".ci).contains)), - KmsDecrypter.stream[IO]() - ) +case class DnsRecordWithCredentials(dnsRecord: UnidentifiedDnsRecord, + cloudflareEmail: CiphertextType, + cloudflareKey: CiphertextType, + ) - private def constructCloudflareClient(resourceProperties: Map[String, Json]): Stream[IO, DnsRecordClient[IO]] = +object DnsRecordWithCredentials { + implicit val decoder: Decoder[DnsRecordWithCredentials] = Decoder[UnidentifiedDnsRecord].flatMap { dnsRecord => + (Decoder[CiphertextType].at("CloudflareEmail"), Decoder[CiphertextType].at("CloudflareKey")) + .mapN(DnsRecordWithCredentials(dnsRecord, _, _)) + } + + implicit val encoder: Encoder[DnsRecordWithCredentials] = Encoder.instance { recordWithCredentials => + recordWithCredentials.asJson.mapObject { + _ + .add("CloudflareEmail", recordWithCredentials.cloudflareEmail.asJson) + .add("CloudflareKey", recordWithCredentials.cloudflareKey.asJson) + } + } +} + +case class NoPlaintextForCiphertext(ciphertext: CiphertextType) + extends RuntimeException(s"KMS returned no plaintext for ciphertext input $ciphertext") + with NoStackTrace + +class CloudflareDnsRecordHandler[F[_] : Concurrent : LoggerFactory : NonEmptyParallel : Trace](httpClient: Client[F], + kms: KMS[F], + ) extends CloudFormationCustomResource[F, DnsRecordWithCredentials, JsonObject] { + private implicit val logger: Logger[F] = LoggerFactory[F].getLogger + + private def constructCloudflareClient(input: DnsRecordWithCredentials): F[DnsRecordClient[F]] = for { - (email, key) <- decryptSensitiveProperties(resourceProperties) - httpClient <- httpClientStream - executor = new StreamingCloudflareApiExecutor[IO](httpClient, CloudflareAuthorization(email, key)) + (email, key) <- decryptSensitiveProperties(input) + executor = new StreamingCloudflareApiExecutor[F](httpClient, CloudflareAuthorization(email.value.toUTF8String, key.value.toUTF8String)) } yield DnsRecordClient(executor) - private def decryptSensitiveProperties(resourceProperties: Map[String, Json]): Stream[IO, (String, String)] = + private def decrypt(ciphertext: CiphertextType): F[PlaintextType] = for { - kmsClient <- kmsClientStream - emailCryptoText <- Stream.emit(resourceProperties("CloudflareEmail")).covary[IO].through(decoder[IO, String]) - keyCryptoText <- Stream.emit(resourceProperties("CloudflareKey")).covary[IO].through(decoder[IO, String]) - plaintextMap <- kmsClient.decryptBase64("CloudflareEmail" -> emailCryptoText, "CloudflareKey" -> keyCryptoText).map(_.mapValues(new String(_, "UTF-8"))) - emailPlaintext = plaintextMap("CloudflareEmail") - keyPlaintext = plaintextMap("CloudflareKey") - } yield (emailPlaintext, keyPlaintext) - - override def handleRequest(input: CloudFormationCustomResourceRequest): IO[HandlerResponse] = - (for { - resourceProperties <- Stream.eval(IO.fromEither(input.ResourceProperties.toRight(MissingResourceProperties))) - dnsRecord <- parseRecordFrom(resourceProperties) - cloudflareClient <- constructCloudflareClient(resourceProperties) - res <- UpdateCloudflare(cloudflareClient)(input.RequestType, dnsRecord, input.PhysicalResourceId) - } yield res).compile.toList.map(_.head) + res <- kms.decrypt(ciphertext) + out <- res.plaintext.toRight(NoPlaintextForCiphertext(ciphertext)).liftTo[F] + } yield out -} + private def decryptSensitiveProperties(input: DnsRecordWithCredentials): F[(PlaintextType, PlaintextType)] = + (decrypt(input.cloudflareEmail), decrypt(input.cloudflareKey)).parTupled -object CloudflareDnsRecordHandler { - implicit class ParseJsonAs(json: Json) { - def parseAs[A](implicit d: Decoder[A]): IO[A] = IO.fromEither(json.as[A]) - } + override def createResource(input: DnsRecordWithCredentials): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(new UpdateCloudflare(_)) + .flatMap(_.handleCreateOrUpdate(input.dnsRecord, None)) + + override def updateResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(new UpdateCloudflare(_)) + .flatMap(_.handleCreateOrUpdate(input.dnsRecord, physicalResourceId.some)) + + override def deleteResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + constructCloudflareClient(input) + .map(new UpdateCloudflare(_)) + .flatMap(_.handleDelete(physicalResourceId)) - def parseRecordFrom(resourceProperties: Map[String, Json]): Stream[IO, UnidentifiedDnsRecord] = - Stream.eval(Json.obj(resourceProperties.toSeq: _*).parseAs[UnidentifiedDnsRecord]) } -object UpdateCloudflare { - private val logger = org.slf4j.LoggerFactory.getLogger("LambdaLogger") - - private implicit def optionToStream[F[_], A](o: Option[A]): Stream[F, A] = Stream.emits(o.toSeq) - - /* Emit the stream, or if it's empty, some alternate value - - ```scala - yourStream.pull.uncons { - case None => Pull.output1(alternateValue) - case Some((hd, tl)) => Pull.output(hd) >> tl.pull.echo - }.stream - ``` - */ - - def apply(cloudflareDnsRecordClient: DnsRecordClient[IO]) - (requestType: String, - unidentifiedDnsRecord: UnidentifiedDnsRecord, - physicalResourceId: Option[String]): Stream[IO, HandlerResponse] = - requestType.toUpperCase match { - case "CREATE" | "UPDATE" => - handleCreateOrUpdate(unidentifiedDnsRecord, physicalResourceId)(cloudflareDnsRecordClient) - case "DELETE" => handleDelete(physicalResourceId.get)(cloudflareDnsRecordClient) +object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceRequest[DnsRecordWithCredentials], Nothing] { + private def httpClient[F[_] : Async : Network : Trace]: Resource[F, Client[F]] = + EmberClientBuilder + .default[F] + .build + .map(middleware.Logger(logHeaders = true, logBody = true, (Headers.SensitiveHeaders + ci"X-Auth-Key").contains)) + .map(NatchezMiddleware.client(_)) + + override def handler: Resource[IO, Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => IO[Option[Nothing]]] = + for { + xray <- XRay.entryPoint[IO]() + implicit0(logger: LoggerFactory[IO]) = ConsoleLoggerFactory.create[IO] + case implicit0(local: Local[IO, Span[IO]]) <- IO.local(Span.noop[IO]).toResource + client <- httpClient[IO] + region <- Env[IO].get("AWS_REGION").liftEitherT(new RuntimeException("missing AWS_REGION environment variable")).map(AwsRegion(_)).rethrowT.toResource + awsEnv <- AwsEnvironment.default(client, region) + kms <- AwsClient(KMS, awsEnv) + } yield { + implicit val kernelSourceCloudFormationCustomResourceRequest: KernelSource[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] = KernelSource.emptyKernelSource + + val f: Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => IO[Option[Nothing]] = implicit inv => + TracedHandler(xray) { implicit trace => + CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms)) + } + + f } +} + +// TODO add tracing instrumentation +class UpdateCloudflare[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]) { - private def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = + def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = unidentifiedDnsRecord.recordType.toUpperCase() match { - case "CNAME" => Stream.eval(handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId)) + case "CNAME" => handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) case _ => handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) } - private def handleDelete(physicalResourceId: String) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { - for { - existingRecord <- cloudflare.getByUri(physicalResourceId) - deleted <- cloudflare.deleteDnsRecord(existingRecord.physicalResourceId) - } yield { - val data = Map( - "deletedRecordId" -> Json.fromString(deleted) - ) + def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId) + : F[HandlerResponse[JsonObject]] = + cloudflare.getByUri(physicalResourceId.value) + .map(_.physicalResourceId) + .flatMap(cloudflare.deleteDnsRecord) + .compile + .toList + .flatMap { + case Nil => warnAboutMissingRecordDeletion(physicalResourceId) + case deleted :: Nil => + val data = JsonObject( + "deletedRecordId" -> deleted.asJson + ) - HandlerResponse(physicalResourceId, data) - } - }.last.evalMap(_.fold(warnAboutMissingRecordDeletion(physicalResourceId))(_.pure[IO])) + HandlerResponse(physicalResourceId, data.some).pure[F] - private def warnAboutMissingRecordDeletion(physicalResourceId: String): IO[HandlerResponse] = - for { - _ <- IO(logger.warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!")) - } yield HandlerResponse(physicalResourceId, Map.empty[String, Json]) + case multipleDeleted => + val data = JsonObject( + "deletedRecordIds" -> multipleDeleted.asJson + ) + + HandlerResponse(physicalResourceId, data.some).pure[F] + } + + private def warnAboutMissingRecordDeletion(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + Logger[F].warn("The record could not be deleted because it did not exist; nonetheless, responding with Success!") + .as(HandlerResponse(physicalResourceId, None)) - private def handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Stream[IO, String]) - (implicit cloudflare: DnsRecordClient[IO]): Stream[IO, HandlerResponse] = { + private def handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]) + : F[HandlerResponse[JsonObject]] = for { - maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.flatMap(cloudflare.getByUri).last + maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.map(_.value).flatTraverse(cloudflare.getByUri(_).compile.last) createOrUpdate <- maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeExistingRecord) - } - private def findAtMostOneExistingCNAME(name: String) - (implicit cloudflare: DnsRecordClient[IO]): IO[Option[IdentifiedDnsRecord]] = + private def findAtMostOneExistingCNAME(name: String): F[Option[IdentifiedDnsRecord]] = cloudflare.getExistingDnsRecords(name, recordType = Option("CNAME")).compile.toList .flatMap { identifiedDnsRecords => - if (identifiedDnsRecords.size < 2) IO.pure(identifiedDnsRecords.headOption) - else IO.raiseError(MultipleCloudflareRecordsExistForDomainNameException(name, identifiedDnsRecords.map { - import com.dwolla.cloudflare.domain.model.Implicits._ + if (identifiedDnsRecords.size < 2) identifiedDnsRecords.headOption.pure[F] + else MultipleCloudflareRecordsExistForDomainNameException(name, identifiedDnsRecords.map { + import com.dwolla.cloudflare.domain.model.Implicits.* _.toDto - }.toSet)) + }.toSet).raiseError } - private def handleCreateOrUpdateCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[String]) - (implicit cloudflare: DnsRecordClient[IO]): IO[HandlerResponse] = + private def handleCreateOrUpdateCNAME(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = for { maybeIdentifiedDnsRecord <- findAtMostOneExistingCNAME(unidentifiedDnsRecord.name) - createOrUpdate <- maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord).compile.toList.map(_.head) + createOrUpdate <- maybeIdentifiedDnsRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) _ <- warnIfProvidedIdDoesNotMatchDiscoveredId(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord, unidentifiedDnsRecord.name) _ <- warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeIdentifiedDnsRecord) - /*_*/ - private def createRecord(implicit cloudflare: DnsRecordClient[IO]): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = + private def createRecord: Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = Kleisli { unidentifiedDnsRecord => - for { - identifiedRecord <- cloudflare.createDnsRecord(unidentifiedDnsRecord).recoverWith { + cloudflare + .createDnsRecord(unidentifiedDnsRecord) + .recoverWith { case RecordAlreadyExists => cloudflare.getExistingDnsRecords(unidentifiedDnsRecord.name, Option(unidentifiedDnsRecord.content), Option(unidentifiedDnsRecord.recordType)) } - } yield Create(identifiedRecord) + .map(CreateOrUpdate.create) + .compile + .lastOrError } - /*_*/ - private def updateRecord(existingRecord: IdentifiedDnsRecord)(implicit cloudflare: DnsRecordClient[IO]): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = - for { - update <- assertRecordTypeWillNotChange(existingRecord.recordType).andThen { unidentifiedDnsRecord => - cloudflare.updateDnsRecord(unidentifiedDnsRecord.identifyAs(existingRecord.physicalResourceId)).map(Update(_)) + private def updateRecord(existingRecord: IdentifiedDnsRecord): Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = + assertRecordTypeWillNotChange(existingRecord.recordType) + .map(_.identifyAs(existingRecord.physicalResourceId)) + .andThen { + cloudflare + .updateDnsRecord(_) + .map(CreateOrUpdate.update) + .compile + .lastOrError } - } yield update - private def warnIfProvidedIdDoesNotMatchDiscoveredId(physicalResourceId: Option[String], updateableRecord: Option[IdentifiedDnsRecord], hostname: String): IO[Unit] = IO { - for { - providedId <- physicalResourceId - discoveredId <- updateableRecord.map(_.physicalResourceId) - if providedId != discoveredId - } logger.warn(s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""") + private def warnIfProvidedIdDoesNotMatchDiscoveredId(physicalResourceId: Option[cloudformation.PhysicalResourceId], + updateableRecord: Option[IdentifiedDnsRecord], + hostname: String): F[Unit] = { + val warning = + for { + providedId <- physicalResourceId + discoveredId <- updateableRecord.map(_.physicalResourceId) + if providedId.value != discoveredId + } yield s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""" + + warning.traverse_(Logger[F].warn(_)) } - private def warnIfNoIdWasProvidedButDnsRecordExisted(physicalResourceId: Option[String], existingRecord: Option[IdentifiedDnsRecord]): IO[Unit] = IO { - if (physicalResourceId.isEmpty) + private def warnIfNoIdWasProvidedButDnsRecordExisted(physicalResourceId: Option[cloudformation.PhysicalResourceId], existingRecord: Option[IdentifiedDnsRecord]): F[Unit] = { + val warning = for { dnsRecord <- existingRecord - discoveredId <- dnsRecord.physicalResourceId - } logger.warn(s"""Discovered DNS record ID "$discoveredId" for hostname "${dnsRecord.name}", with existing content "${dnsRecord.content}". This record will be updated instead of creating a new record.""") + discoveredId = dnsRecord.physicalResourceId + if (physicalResourceId.isEmpty) + } yield s"""Discovered DNS record ID "$discoveredId" for hostname "${dnsRecord.name}", with existing content "${dnsRecord.content}". This record will be updated instead of creating a new record.""" + + warning.traverse_(Logger[F].warn(_)) } - private def createOrUpdateToHandlerResponse(createOrUpdate: CreateOrUpdate[IdentifiedDnsRecord], existingRecord: Option[IdentifiedDnsRecord]): HandlerResponse = { + private def createOrUpdateToHandlerResponse(createOrUpdate: CreateOrUpdate[IdentifiedDnsRecord], existingRecord: Option[IdentifiedDnsRecord]): HandlerResponse[JsonObject] = { val dnsRecord = createOrUpdate.value - val data = Map( + val data = JsonObject( "dnsRecord" -> dnsRecord.asJson, "created" -> createOrUpdate.create.asJson, "updated" -> createOrUpdate.update.asJson, "oldDnsRecord" -> existingRecord.asJson, ) - HandlerResponse(dnsRecord.physicalResourceId, data) + HandlerResponse(cloudformation.PhysicalResourceId.unsafeApply(dnsRecord.physicalResourceId), data.some) } - /*_*/ - private def assertRecordTypeWillNotChange(existingRecordType: String): Kleisli[Stream[IO, ?], UnidentifiedDnsRecord, UnidentifiedDnsRecord] = + private def assertRecordTypeWillNotChange(existingRecordType: String): Kleisli[F, UnidentifiedDnsRecord, UnidentifiedDnsRecord] = Kleisli { unidentifiedDnsRecord => if (unidentifiedDnsRecord.recordType == existingRecordType) - Stream.emit(unidentifiedDnsRecord) + unidentifiedDnsRecord.pure else - Stream.raiseError(DnsRecordTypeChange(existingRecordType, unidentifiedDnsRecord.recordType)) + DnsRecordTypeChange(existingRecordType, unidentifiedDnsRecord.recordType).raiseError } - /*_*/ - - case class DnsRecordTypeChange(existingRecordType: String, newRecordType: String) - extends RuntimeException(s"""Refusing to change DNS record from "$existingRecordType" to "$newRecordType".""") } + +case class DnsRecordTypeChange(existingRecordType: String, newRecordType: String) + extends RuntimeException(s"""Refusing to change DNS record from "$existingRecordType" to "$newRecordType".""") diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala new file mode 100644 index 0000000..12762f1 --- /dev/null +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CreateOrUpdate.scala @@ -0,0 +1,44 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.* +import cats.syntax.all.* + +sealed trait CreateOrUpdate[+A] extends Product with Serializable { + val value: A + def create: Option[A] = None + def update: Option[A] = None +} + +final case class Create[A](value: A) extends CreateOrUpdate[A] { + override def create: Option[A] = Some(value) +} + +final case class Update[A](value: A) extends CreateOrUpdate[A] { + override def update: Option[A] = Some(value) +} + +object CreateOrUpdate { + + def create[A](a: A): CreateOrUpdate[A] = Create(a) + def update[A](a: A): CreateOrUpdate[A] = Update(a) + + implicit val traverseInstance: Traverse[CreateOrUpdate] = new Traverse[CreateOrUpdate] { + override def traverse[G[_]: Applicative, A, B](fa: CreateOrUpdate[A]) + (f: A => G[B]): G[CreateOrUpdate[B]] = + fa match { + case Create(a) => f(a).map(Create(_)) + case Update(a) => f(a).map(Update(_)) + } + + override def foldLeft[A, B](fa: CreateOrUpdate[A], b: B)(f: (B, A) => B): B = f(b, fa.value) + override def foldRight[A, B](fa: CreateOrUpdate[A], lb: Eval[B])(f: (A, Eval[B]) => Eval[B]): Eval[B] = f(fa.value, lb) + } + + implicit def createOrUpdateEq[A: Eq]: Eq[CreateOrUpdate[A]] = + (x: CreateOrUpdate[A], y: CreateOrUpdate[A]) => (x, y) match { + case (Create(a), Create(b)) => Eq[A].eqv(a, b) + case (Update(a), Update(b)) => Eq[A].eqv(a, b) + case _ => false + } + +} diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala new file mode 100644 index 0000000..6c68518 --- /dev/null +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala @@ -0,0 +1,90 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.syntax.all.* +import io.circe.* +import io.circe.syntax.* +import smithy.api.TimestampFormat +import smithy4s.* +import smithy4s.Document.{DArray, DBoolean, DNull, DNumber, DObject, DString} +import smithy4s.schema.{Alt, CachedSchemaCompiler, CollectionTag, CompilationCache, EnumTag, EnumValue, Field, Primitive, Schema, SchemaVisitor} +import smithy4s.json.Json + +import java.util.Base64 +import scala.util.Try + +object DocumentFolder extends io.circe.Json.Folder[Option[Document]] { + override def onNull: Option[Document] = DNull.some + override def onBoolean(value: Boolean): Option[Document] = DBoolean(value).some + override def onNumber(value: JsonNumber): Option[Document] = value.toBigDecimal.map(DNumber(_)) + override def onString(value: String): Option[Document] = DString(value).some + override def onArray(value: Vector[Json]): Option[Document] = value.traverse(_.foldWith(this)).map(DArray(_)) + override def onObject(value: JsonObject): Option[Document] = + value + .toList + .traverse { case (k, v) => + v.foldWith(this).map(k -> _) + } + .map(_.toMap) + .map(DObject(_)) +} + +object SchemaVisitorCirceCodec extends CachedSchemaCompiler.Impl[Codec] { + override protected type Aux[A] = Codec[A] + + override def fromSchema[A](schema: Schema[A], cache: CompilationCache[Codec]): Codec[A] = + schema.compile(new SchemaVisitorCirceCodec(cache)) +} + +class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Codec]) extends SchemaVisitor.Cached[Codec] { + self => + + private implicit val blobEncoder: Encoder[Blob] = Encoder.encodeString.contramap(_.toBase64String) + private implicit val blobDecoder: Decoder[Blob] = Decoder[String].emapTry { base64String => + Try(Base64.getDecoder.decode(base64String)).map(Blob(_)) + } + + private implicit def documentEncoder: Encoder[Document] = Encoder.instance { + case DNumber(value) => value.asJson + case DString(value) => value.asJson + case DBoolean(value) => value.asJson + case DNull => io.circe.Json.Null + case DArray(value) => io.circe.Json.fromValues(value.map(_.asJson(documentEncoder))) + case DObject(value) => io.circe.Json.fromFields(value.map { case (k, v) => k -> v.asJson(documentEncoder) }) + } + private implicit def documentDecoder: Decoder[Document] = Decoder.instance { c => + c.value.foldWith(DocumentFolder).toRight(DecodingFailure("Could not decode document", c.history)) + } + + private implicit val timestampEncoder: Encoder[Timestamp] = Encoder[String].contramap(_.format(TimestampFormat.DATE_TIME)) + private implicit val timestampDecoder: Decoder[Timestamp] = Decoder[String].emap(Timestamp.parse(_, TimestampFormat.DATE_TIME).toRight(s"Could not parse timestamp; expected ${Timestamp.showFormat(TimestampFormat.DATE_TIME)}")) + + override def primitive[P](shapeId: ShapeId, + hints: Hints, + tag: Primitive[P]): Codec[P] = { + implicit def implied[A: Encoder : Decoder]: Codec[A] = Codec.implied + Primitive.deriving[Codec].apply(tag) + } + + override def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Codec[C[A]] = ??? + + override def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): Codec[Map[K, V]] = { + + + io.circe.Json.obj() + } + + override def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): Codec[E] = ??? + + override def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): Codec[S] = ??? + + override def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], dispatch: Alt.Dispatcher[U]): Codec[U] = ??? + + override def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): Codec[B] = ??? + + override def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): Codec[B] = ??? + + override def lazily[A](suspend: Lazy[Schema[A]]): Codec[A] = ??? + + override def option[A](schema: Schema[A]): Codec[Option[A]] = ??? + +} diff --git a/src/main/scala/io/circe/JsoniterScalaCodec.scala b/src/main/scala/io/circe/JsoniterScalaCodec.scala new file mode 100644 index 0000000..6cdd0f9 --- /dev/null +++ b/src/main/scala/io/circe/JsoniterScalaCodec.scala @@ -0,0 +1,44 @@ +package io.circe + +import com.github.plokhotnyuk.jsoniter_scala.core._ + +/** + * Bridge utilities for using a Jsoniter JsonValueCodec[A] as a Circe Codec[A]. + * + * This is a minimal adapter that: + * - Encodes by serializing with Jsoniter to a compact JSON string and parsing it into io.circe.Json + * - Decodes by printing a compact JSON string from io.circe.Json and reading it with Jsoniter + * + * It relies on the jsoniter-scala-core and circe-parser modules transitively (parser is part of circe-core in 0.14.x via jawn). + */ +object JsoniterScalaCodec { + /** Build a Circe Codec[A] from a Jsoniter JsonValueCodec[A]. */ + def fromJsoniter[A](implicit jc: JsonValueCodec[A]): Codec[A] = { + val enc: Encoder[A] = Encoder.instance { a => + // Use Jsoniter to serialize, then parse into Circe Json + val s = writeToString(a)(jc) + io.circe.parser.parse(s) match { + case Right(json) => json + case Left(err) => + // Encoder can't fail in its type, so throw if something goes very wrong + throw err + } + } + + val dec: Decoder[A] = Decoder.instance { c => + // Render the incoming Circe Json to a compact string, then read with Jsoniter + val jsonStr = Printer.noSpaces.print(c.value) + try { + Right(readFromString[A](jsonStr)(jc)) + } catch { + case e: JsonReaderException => Left(DecodingFailure(e.getMessage, c.history)) + case e: Throwable => Left(DecodingFailure(e.getMessage, c.history)) + } + } + + Codec.from(dec, enc) + } + + /** Implicitly provide a Circe Codec[A] wherever a Jsoniter JsonValueCodec[A] is in scope. */ + implicit def circeCodecFromJsoniter[A](implicit jc: JsonValueCodec[A]): Codec[A] = fromJsoniter[A] +} diff --git a/src/main/smithy/KMS.smithy b/src/main/smithy/KMS.smithy new file mode 100644 index 0000000..dfb80ff --- /dev/null +++ b/src/main/smithy/KMS.smithy @@ -0,0 +1,7 @@ +$version: "1.0" + +namespace com.dwolla.aws.kms + +use smithy4s.meta#only + +apply com.amazonaws.kms#Decrypt @only From 661905835ad1449d3ee4a15f5573517e82e60fbe Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 24 Sep 2025 15:09:28 -0500 Subject: [PATCH 05/11] WIP update production code --- build.sbt | 3 + .../record/CloudflareDnsRecordHandler.scala | 64 ++++-- .../record/SchemaVisitorCirceCodec.scala | 213 +++++++++++++++--- 3 files changed, 237 insertions(+), 43 deletions(-) diff --git a/build.sbt b/build.sbt index daf0ab9..b1e4761 100644 --- a/build.sbt +++ b/build.sbt @@ -13,7 +13,10 @@ lazy val `cloudflare-public-hostname-lambda` = project libraryDependencies ++= { Seq( "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.3.1", + "org.typelevel" %%% "cats-tagless-macros" % "0.16.3", "com.dwolla" %%% "cloudflare-api-client" % "4.0.0-M16", + "com.dwolla" %%% "natchez-tagless" % "0.2.6", + "com.disneystreaming.smithy4s" %%% "smithy4s-cats" % smithy4sVersion.value, "com.disneystreaming.smithy4s" %%% "smithy4s-http4s" % smithy4sVersion.value, "com.disneystreaming.smithy4s" %%% "smithy4s-aws-http4s" % smithy4sVersion.value, "com.disneystreaming.smithy4s" %%% "smithy4s-json" % smithy4sVersion.value, diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index de03615..033cb45 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -9,6 +9,8 @@ import cats.effect.std.Env import cats.effect.{Trace as _, *} import cats.mtl.Local import cats.syntax.all.* +import cats.tagless.aop.* +import cats.tagless.Derive import com.amazonaws.kms.{CiphertextType, KMS, PlaintextType} import com.dwolla.cloudflare.* import com.dwolla.cloudflare.domain.model.* @@ -16,6 +18,7 @@ import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists import feral.lambda.cloudformation.{CloudFormationCustomResource, CloudFormationCustomResourceRequest, HandlerResponse} import feral.lambda.{IOLambda, Invocation, KernelSource, TracedHandler, cloudformation} import fs2.io.net.Network +import fs2.Stream import mouse.all.* import natchez.* import natchez.http4s.* @@ -31,6 +34,9 @@ import smithy4s.aws.kernel.AwsRegion import smithy4s.aws.{AwsClient, AwsEnvironment} import _root_.io.circe.JsoniterScalaCodec.* import smithy4s.json.Json.* +import NothingEncoder.* +import com.dwolla.tracing.syntax.* +import com.dwolla.tracing.LowPriorityTraceableValueInstances.* import scala.util.control.NoStackTrace @@ -80,21 +86,26 @@ class CloudflareDnsRecordHandler[F[_] : Concurrent : LoggerFactory : NonEmptyPar override def createResource(input: DnsRecordWithCredentials): F[HandlerResponse[JsonObject]] = constructCloudflareClient(input) - .map(new UpdateCloudflare(_)) + .map(UpdateCloudflare(_)) .flatMap(_.handleCreateOrUpdate(input.dnsRecord, None)) override def updateResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = constructCloudflareClient(input) - .map(new UpdateCloudflare(_)) + .map(UpdateCloudflare(_)) .flatMap(_.handleCreateOrUpdate(input.dnsRecord, physicalResourceId.some)) override def deleteResource(input: DnsRecordWithCredentials, physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = constructCloudflareClient(input) - .map(new UpdateCloudflare(_)) + .map(UpdateCloudflare(_)) .flatMap(_.handleDelete(physicalResourceId)) } +object NothingEncoder { + @annotation.nowarn("msg=dead code following this construct") + implicit val encoder: Encoder[Nothing] = Encoder.instance[Nothing](_ => Json.Null) +} + object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceRequest[DnsRecordWithCredentials], Nothing] { private def httpClient[F[_] : Async : Network : Trace]: Resource[F, Client[F]] = EmberClientBuilder @@ -124,23 +135,40 @@ object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceR } } -// TODO add tracing instrumentation -class UpdateCloudflare[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]) { +trait UpdateCloudflare[F[_]] { + def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] + + def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] +} + +object UpdateCloudflare { + implicit val physicalResourceIdTraceableValue: TraceableValue[cloudformation.PhysicalResourceId] = TraceableValue[String].contramap(_.value) + implicit val aspect: Aspect[UpdateCloudflare, TraceableValue, TraceableValue] = Derive.aspect + + def apply[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]): UpdateCloudflare[F] = + (new UpdateCloudflareImpl(cloudflare): UpdateCloudflare[F]).traceWithInputsAndOutputs +} + +class UpdateCloudflareImpl[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]) extends UpdateCloudflare[F] { def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, - cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = + cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = { unidentifiedDnsRecord.recordType.toUpperCase() match { case "CNAME" => handleCreateOrUpdateCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) case _ => handleCreateOrUpdateNonCNAME(unidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId) } + } - def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId) - : F[HandlerResponse[JsonObject]] = - cloudflare.getByUri(physicalResourceId.value) - .map(_.physicalResourceId) - .flatMap(cloudflare.deleteDnsRecord) - .compile - .toList + def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] = + Trace[F].span("DnsRecordClient.getByUri >> DnsRecordClient.deleteDnsRecord") { + Trace[F].put("physicalResourceId.input" -> physicalResourceId) >> + cloudflare.getByUri(physicalResourceId.value) + .map(_.physicalResourceId) + .flatMap(id => Stream.eval(Trace[F].put("physicalResourceId.found" -> id)) >> cloudflare.deleteDnsRecord(id)) + .compile + .toList + } .flatMap { case Nil => warnAboutMissingRecordDeletion(physicalResourceId) case deleted :: Nil => @@ -166,7 +194,13 @@ class UpdateCloudflare[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecord cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]) : F[HandlerResponse[JsonObject]] = for { - maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.map(_.value).flatTraverse(cloudflare.getByUri(_).compile.last) + maybeExistingRecord <- cloudformationProvidedPhysicalResourceId.map(_.value).flatTraverse(str => Trace[F].span("DnsRecordClient.getByUri") { + for { + _ <- Trace[F].put("physicalResourceId.input" -> str) + out <- cloudflare.getByUri(str).compile.last + _ <- Trace[F].put("output" -> out) + } yield out + }) createOrUpdate <- maybeExistingRecord.fold(createRecord)(updateRecord).run(unidentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeExistingRecord) @@ -189,6 +223,7 @@ class UpdateCloudflare[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecord _ <- warnIfNoIdWasProvidedButDnsRecordExisted(cloudformationProvidedPhysicalResourceId, maybeIdentifiedDnsRecord) } yield createOrUpdateToHandlerResponse(createOrUpdate, maybeIdentifiedDnsRecord) + // TODO add tracing private def createRecord: Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = Kleisli { unidentifiedDnsRecord => cloudflare @@ -202,6 +237,7 @@ class UpdateCloudflare[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecord .lastOrError } + // TODO add tracing private def updateRecord(existingRecord: IdentifiedDnsRecord): Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = assertRecordTypeWillNotChange(existingRecord.recordType) .map(_.identifyAs(existingRecord.physicalResourceId)) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala index 6c68518..ea0caf0 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala @@ -6,28 +6,11 @@ import io.circe.syntax.* import smithy.api.TimestampFormat import smithy4s.* import smithy4s.Document.{DArray, DBoolean, DNull, DNumber, DObject, DString} -import smithy4s.schema.{Alt, CachedSchemaCompiler, CollectionTag, CompilationCache, EnumTag, EnumValue, Field, Primitive, Schema, SchemaVisitor} -import smithy4s.json.Json +import smithy4s.schema.{Schema, *} import java.util.Base64 import scala.util.Try -object DocumentFolder extends io.circe.Json.Folder[Option[Document]] { - override def onNull: Option[Document] = DNull.some - override def onBoolean(value: Boolean): Option[Document] = DBoolean(value).some - override def onNumber(value: JsonNumber): Option[Document] = value.toBigDecimal.map(DNumber(_)) - override def onString(value: String): Option[Document] = DString(value).some - override def onArray(value: Vector[Json]): Option[Document] = value.traverse(_.foldWith(this)).map(DArray(_)) - override def onObject(value: JsonObject): Option[Document] = - value - .toList - .traverse { case (k, v) => - v.foldWith(this).map(k -> _) - } - .map(_.toMap) - .map(DObject(_)) -} - object SchemaVisitorCirceCodec extends CachedSchemaCompiler.Impl[Codec] { override protected type Aux[A] = Codec[A] @@ -61,30 +44,202 @@ class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Cod override def primitive[P](shapeId: ShapeId, hints: Hints, tag: Primitive[P]): Codec[P] = { - implicit def implied[A: Encoder : Decoder]: Codec[A] = Codec.implied - Primitive.deriving[Codec].apply(tag) + val enc: Encoder[P] = Primitive.deriving[Encoder].apply(tag) + val dec: Decoder[P] = Primitive.deriving[Decoder].apply(tag) + Codec.from(dec, enc) } - override def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Codec[C[A]] = ??? + override def collection[C[_], A](shapeId: ShapeId, hints: Hints, tag: CollectionTag[C], member: Schema[A]): Codec[C[A]] = { + implicit val schemaA: Schema[A] = member + + val encoder: Encoder[C[A]] = Encoder.instance { (ca: C[A]) => + Document.array(tag.iterator(ca).map(Document.encode(_)).to(Iterable)).asJson +// Document.array(ca.map(Document.encode(_)).toIterable).asJson + } + + val decoder: Decoder[C[A]] = Decoder.instance { cursor => + cursor + .values + .toRight(DecodingFailure("Could not decode document", cursor.history)) + .flatMap(_.toList.traverse { + _.foldWith(DocumentFolder) + .toRight(DecodingFailure("Could not decode document", cursor.history)) + .flatMap(_.decode[A].leftMap(DecodingFailure.fromThrowable(_, cursor.history))) + }) + .map(_.iterator) + .map(tag.fromIterator) + } + + Codec.from(decoder, encoder) + } override def map[K, V](shapeId: ShapeId, hints: Hints, key: Schema[K], value: Schema[V]): Codec[Map[K, V]] = { + implicit val keySchema: Schema[K] = key + val keyEncoder: KeyEncoder[K] = KeyEncoder.instance { (k: K) => + Document.encode(k) match { + case DString(s) => s + case other => other.toString + } + } + val keyDecoder: KeyDecoder[K] = KeyDecoder.instance { (s: String) => + DString(s).decode[K].toOption + } + implicit val valueCodec: Codec[V] = SchemaVisitorCirceCodec.fromSchema(value, cache) + + val encoder: Encoder[Map[K, V]] = Encoder.instance { (map: Map[K, V]) => + io.circe.Json.fromFields(map.toList.map { case (k, v) => + keyEncoder(k) -> v.asJson + }) + } + + val decoder: Decoder[Map[K, V]] = Decoder.instance { cursor => + cursor.keys + .toRight(DecodingFailure("not an object", cursor.history)) + .map(_.toList) + .flatMap { + _.traverse { (key: String) => + for { + k <- keyDecoder(key).toRight(DecodingFailure(s"Could not decode key $key", cursor.history)) + v <- cursor.downField(key).as[V] + } yield k -> v + } + } + .map(_.toMap) + } + + Codec.from(decoder, encoder) + } + override def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): Codec[E] = { + val encoder: Encoder[E] = Encoder.instance(e => io.circe.Json.fromString(total(e).stringValue)) + val decoder: Decoder[E] = Decoder.instance { cursor => + cursor.as[String].flatMap { str => + values + .find(_.stringValue == str) + .map(_.value) + .toRight(DecodingFailure(s"Invalid enumeration value: $str", cursor.history)) + } + } + Codec.from(decoder, encoder) + } - io.circe.Json.obj() + override def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, ?]], make: IndexedSeq[Any] => S): Codec[S] = { + final case class EncF[A](field: Field[S, A], enc: Encoder[A], get: S => A) + final case class DecF[A](field: Field[S, A], dec: Decoder[A]) + + val encFields: Vector[EncF[?]] = fields.map { f0 => + val f = f0.asInstanceOf[Field[S, Any]] + val codec = SchemaVisitorCirceCodec.fromSchema(f.schema, cache) + EncF(f, codec.asInstanceOf[Encoder[Any]], f.get(_)) + } + + val decFields: Vector[DecF[?]] = fields.map { f0 => + val schAny = f0.schema + val decAny = SchemaVisitorCirceCodec.fromSchema(schAny, cache).asInstanceOf[Decoder[Any]] + DecF(f0.asInstanceOf[Field[S, Any]], decAny) + } + + val encoder: Encoder[S] = Encoder.instance { (s: S) => + val kvs: Iterable[(String, Json)] = encFields.iterator.flatMap { ef => + val label = ef.field.label + val v: Any = ef.get(s) + v match { + case opt: Option[_] => opt.map(o => label -> ef.enc.asInstanceOf[Encoder[Any]].apply(o)) + case other => Some(label -> ef.enc.asInstanceOf[Encoder[Any]].apply(other)) + } + }.toList + io.circe.Json.obj(kvs.toSeq *) + } + + val decoder: Decoder[S] = Decoder.instance { cursor => + decFields.zipWithIndex.toList + .traverse { case (df, _) => + val label = df.field.label + val sub = cursor.downField(label) + sub.as(df.dec) + } + .map(vec => make(vec.toIndexedSeq)) + } + + Codec.from(decoder, encoder) } - override def enumeration[E](shapeId: ShapeId, hints: Hints, tag: EnumTag[E], values: List[EnumValue[E]], total: E => EnumValue[E]): Codec[E] = ??? + override def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, ?]], dispatch: Alt.Dispatcher[U]): Codec[U] = { + final case class AltInfo[A](alt: Alt[U, A], enc: Encoder[A], dec: Decoder[A]) + + val alts: Vector[AltInfo[?]] = alternatives.map { a0 => + val a = a0.asInstanceOf[Alt[U, Any]] + val codec = SchemaVisitorCirceCodec.fromSchema(a.schema, cache) + AltInfo(a0.asInstanceOf[Alt[U, Any]], codec.asInstanceOf[Encoder[Any]], codec.asInstanceOf[Decoder[Any]]) + } + + val encoder: Encoder[U] = Encoder.instance { (u: U) => + val fieldOpt: Option[(String, Json)] = alts.iterator.flatMap { ai => + val a = ai.alt.asInstanceOf[Alt[U, Any]] + a.project.lift(u).map { v => a.label -> ai.enc.asInstanceOf[Encoder[Any]].apply(v) } + }.toSeq.headOption + fieldOpt match { + case Some((label, json)) => Json.obj(label -> json) + case None => Json.obj() + } + } + + val decoder: Decoder[U] = Decoder.instance { c => + c.value.asObject.toRight(DecodingFailure("not an object", c.history)).flatMap { obj => + obj.toMap.toList match { + case (label, _) :: Nil => + alternatives.find(_.label == label).toRight(DecodingFailure(s"Unknown union alternative: $label", c.history)).flatMap { a0 => + val a = a0.asInstanceOf[Alt[U, Any]] + val decAny = SchemaVisitorCirceCodec.fromSchema(a.schema, cache).asInstanceOf[Decoder[Any]] + c.downField(label).as(decAny).map(v => a.inject(v)) + } + case Nil => Left(DecodingFailure("empty object for union", c.history)) + case _ => Left(DecodingFailure("expected single-field object for union", c.history)) + } + } + } - override def struct[S](shapeId: ShapeId, hints: Hints, fields: Vector[Field[S, _]], make: IndexedSeq[Any] => S): Codec[S] = ??? + Codec.from(decoder, encoder) + } - override def union[U](shapeId: ShapeId, hints: Hints, alternatives: Vector[Alt[U, _]], dispatch: Alt.Dispatcher[U]): Codec[U] = ??? + override def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): Codec[B] = { + val ca: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + val encB: Encoder[B] = ca.contramap[B](bijection.from) + val decB: Decoder[B] = ca.map(bijection.to) + Codec.from(decB, encB) + } - override def biject[A, B](schema: Schema[A], bijection: Bijection[A, B]): Codec[B] = ??? + override def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): Codec[B] = { + val ca: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + val encB: Encoder[B] = ca.contramap(refinement.from) + val decB: Decoder[B] = ca.emap(refinement.apply) + Codec.from(decB, encB) + } - override def refine[A, B](schema: Schema[A], refinement: Refinement[A, B]): Codec[B] = ??? + override def lazily[A](suspend: Lazy[Schema[A]]): Codec[A] = { + lazy val compiled: Codec[A] = SchemaVisitorCirceCodec.fromSchema(suspend.value, cache) + compiled + } - override def lazily[A](suspend: Lazy[Schema[A]]): Codec[A] = ??? + override def option[A](schema: Schema[A]): Codec[Option[A]] = { + implicit val c: Codec[A] = SchemaVisitorCirceCodec.fromSchema(schema, cache) + Codec.from(Decoder.decodeOption[A], Encoder.encodeOption[A]) + } - override def option[A](schema: Schema[A]): Codec[Option[A]] = ??? +} +object DocumentFolder extends io.circe.Json.Folder[Option[Document]] { + override def onNull: Option[Document] = DNull.some + override def onBoolean(value: Boolean): Option[Document] = DBoolean(value).some + override def onNumber(value: JsonNumber): Option[Document] = value.toBigDecimal.map(DNumber(_)) + override def onString(value: String): Option[Document] = DString(value).some + override def onArray(value: Vector[Json]): Option[Document] = value.traverse(_.foldWith(this)).map(DArray(_)) + override def onObject(value: JsonObject): Option[Document] = + value + .toList + .traverse { case (k, v) => + v.foldWith(this).map(k -> _) + } + .map(_.toMap) + .map(DObject(_)) } From b214366ac303ee836b0b45aa528aecb448bebdef Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 14:06:35 -0500 Subject: [PATCH 06/11] wip --- .dwollaci.yml | 22 + .gitignore | 4 +- .nvmrc | 1 + build.sbt | 25 +- cdk/.dwollaci.yml | 50 - cdk/cdk.json | 4 - cdk/package-lock.json | 632 -------- cdk/package.json | 38 - cdk/src/CloudflarePublicHostnameStack.ts | 69 - cdk/src/index.ts | 6 - cdk/tsconfig.json | 18 - deploy.sh | 49 + project/CdkDeployPlugin.scala | 63 + project/CdkPlugin.scala | 35 - project/LambdaStack.scala | 95 ++ project/Version.scala | 82 + project/plugins.sbt | 7 +- .../record/CloudflareDnsRecordHandler.scala | 99 +- .../record/SchemaVisitorCirceCodec.scala | 8 +- .../lambda/cloudflare/record/package.scala | 16 +- .../scala/io/circe/JsoniterScalaCodec.scala | 4 +- .../CloudflareDnsRecordHandlerSpec.scala | 1411 ++++++++++------- .../record/FakeDnsRecordClient.scala | 26 + .../record/UpdateCloudflareSuite.scala | 192 +++ 24 files changed, 1453 insertions(+), 1503 deletions(-) create mode 100644 .dwollaci.yml create mode 100644 .nvmrc delete mode 100644 cdk/.dwollaci.yml delete mode 100644 cdk/cdk.json delete mode 100644 cdk/package-lock.json delete mode 100644 cdk/package.json delete mode 100644 cdk/src/CloudflarePublicHostnameStack.ts delete mode 100644 cdk/src/index.ts delete mode 100644 cdk/tsconfig.json create mode 100755 deploy.sh create mode 100644 project/CdkDeployPlugin.scala delete mode 100644 project/CdkPlugin.scala create mode 100644 project/LambdaStack.scala create mode 100644 project/Version.scala create mode 100644 src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala create mode 100644 src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala diff --git a/.dwollaci.yml b/.dwollaci.yml new file mode 100644 index 0000000..a6bb143 --- /dev/null +++ b/.dwollaci.yml @@ -0,0 +1,22 @@ +stages: + enableCD: true + build: + nodeLabel: sbt + steps: + - | + export SDKMAN_DIR="$HOME/.sdkman" + mkdir -p "${SDKMAN_DIR}/candidates/java/current/bin" + set +o xtrace + . "${SDKMAN_DIR}/bin/sdkman-init.sh" + sdk env install use + set -o xtrace + sbt test + sbt doc + sbt autoscaling-ecs-draining-lambda/Universal/packageBin + sbt registrator-health-check-lambda/Universal/packageBin + filesToStash: + - '**' + deployProd: + nodeLabel: nvm-sbt-deployer + steps: + - ./deploy.sh diff --git a/.gitignore b/.gitignore index a5c2ad3..85c9788 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ target/ project/project/ node_modules/ -.bsp/ \ No newline at end of file +.bsp/ +package.json +package-lock.json diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..deed13c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/jod diff --git a/build.sbt b/build.sbt index b1e4761..0f11a98 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,8 @@ evictionErrorLevel := Level.Warn ThisBuild / organization := "Dwolla" ThisBuild / homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")) -ThisBuild / scalaVersion := "2.13.16" +ThisBuild / scalaVersion := "3.7.3" +ThisBuild / resolvers += Resolver.sonatypeCentralSnapshots lazy val `cloudflare-public-hostname-lambda` = project .in(file(".")) @@ -10,11 +11,12 @@ lazy val `cloudflare-public-hostname-lambda` = project name := "cloudflare-public-hostname-lambda", smithy4sAwsSpecs ++= Seq(AWS.kms), scalacOptions += "-Wconf:src=src_managed/.*:s", + dependencyOverrides += "org.scala-lang" %% "scala3-library" % scalaVersion.value, libraryDependencies ++= { Seq( - "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.3.1", - "org.typelevel" %%% "cats-tagless-macros" % "0.16.3", - "com.dwolla" %%% "cloudflare-api-client" % "4.0.0-M16", + "org.typelevel" %%% "feral-lambda-cloudformation-custom-resource" % "0.3.1-68-4c217bd-20251016T232327Z-SNAPSHOT", + "org.typelevel" %%% "cats-tagless-core" % "0.16.3", + "com.dwolla" %%% "cloudflare-api-client" % "4.0-827c1e4-SNAPSHOT", "com.dwolla" %%% "natchez-tagless" % "0.2.6", "com.disneystreaming.smithy4s" %%% "smithy4s-cats" % smithy4sVersion.value, "com.disneystreaming.smithy4s" %%% "smithy4s-http4s" % smithy4sVersion.value, @@ -25,11 +27,22 @@ lazy val `cloudflare-public-hostname-lambda` = project "org.tpolecat" %%% "natchez-mtl" % "0.3.8", "org.tpolecat" %%% "natchez-xray" % "0.3.8", "org.tpolecat" %%% "natchez-http4s" % "0.6.1", + "org.tpolecat" %%% "natchez-http4s-mtl" % "0.6.1", "org.typelevel" %%% "log4cats-core" % "2.7.1", "org.typelevel" %%% "log4cats-js-console" % "2.7.1", "com.github.plokhotnyuk.jsoniter-scala" %%% "jsoniter-scala-circe" % "2.38.0", + "org.typelevel" %%% "munit-cats-effect" % "2.1.0" % Test, + "org.scalameta" %%% "munit" % "1.2.0" % Test, + "org.scalameta" %%% "munit-scalacheck" % "1.2.0" % Test, + "org.typelevel" %%% "scalacheck-effect-munit" % "2.1.0-RC1" % Test, + "org.tpolecat" %%% "natchez-testkit" % "0.3.8", ) }, - updateOptions := updateOptions.value.withCachedResolution(false), + buildInfoKeys := Seq[BuildInfoKey]( + name, + version, + ), + buildInfoPackage := "com.dwolla.lambda.cloudflare.record", + ) - .enablePlugins(Smithy4sCodegenPlugin, LambdaJSPlugin) + .enablePlugins(BuildInfoPlugin, CdkDeployPlugin, LambdaJSPlugin, Smithy4sCodegenPlugin) diff --git a/cdk/.dwollaci.yml b/cdk/.dwollaci.yml deleted file mode 100644 index cdf29c2..0000000 --- a/cdk/.dwollaci.yml +++ /dev/null @@ -1,50 +0,0 @@ -stages: - build: - nodeLabel: jetstream - steps: - - make --directory tasks all - - ENVIRONMENT=Admin make --directory tasks common/docker/jenkins/ref/jenkins.yml - - make --directory stack jenkins-build - filesToStash: - - common/docker/** - - stack/Berksfile - - stack/Berksfile.lock - - stack/Makefile - - stack/stack.json - - stack/policies/** - - stack/docker-compose.yml - - stack/bootstrap.sh - - tasks/** - prepublish: - nodeLabel: chef-deployer - steps: - - make --directory stack jenkins-prepublish - dockerPublish: - dockerImages: - - imageName: jenkins - dockerfile: common/docker/Dockerfile - context: . - buildArgs: - VCS_REF: '{{GIT_COMMIT}}' - VCS_URL: '{{GIT_URL}}' - BUILD_DATE: '{{DATE}}' - VERSION: '{{GIT_COMMIT}}' - JENKINS_VERSION: "2.387.1" - destinations: - - registry: docker.dwolla.net/dwolla - tags: - - '{{GIT_COMMIT}}' - - latest - deployProd: - nodeLabel: jetstream-deployer - steps: - - ENVIRONMENT=Admin make --directory tasks deploy - - | - cd stack && \ - jetstream provision \ - --revision $GIT_COMMIT \ - --environment Admin \ - --region us-west-2 \ - --config ./stack.json \ - --skip-waiting \ - --volume-id vol-09fa6d8bed5eac248 diff --git a/cdk/cdk.json b/cdk/cdk.json deleted file mode 100644 index 762cf97..0000000 --- a/cdk/cdk.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "app": "npx ts-node src/index.ts", - "versionReporting": false -} \ No newline at end of file diff --git a/cdk/package-lock.json b/cdk/package-lock.json deleted file mode 100644 index a3e4185..0000000 --- a/cdk/package-lock.json +++ /dev/null @@ -1,632 +0,0 @@ -{ - "name": "cdk", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cdk", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@aws-cdk/aws-codestar-alpha": "2.41.0-alpha.0", - "aws-cdk": "2.41.0", - "aws-cdk-lib": "2.41.0", - "constructs": "10.1.107", - "dotenv": "16.0.2", - "prettier": "2.7.1", - "rimraf": "^3.0.2" - }, - "devDependencies": { - "@types/node": "18.7.18", - "mocked-env": "1.3.5", - "ts-node": "10.9.1", - "typescript": "4.8.3" - } - }, - "node_modules/@aws-cdk/aws-codestar-alpha": { - "version": "2.41.0-alpha.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/aws-codestar-alpha/-/aws-codestar-alpha-2.41.0-alpha.0.tgz", - "integrity": "sha512-RIpFxuCYUv6k5uUO6b/9pk/YHpNfgDMEHh9HqEIQQYfAzEWpdUj8NGE0e3Q+/h9/jlbGzHhfxWZtxbS0r2w5cA==", - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "aws-cdk-lib": "^2.41.0", - "constructs": "^10.0.0" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", - "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", - "dev": true - }, - "node_modules/@types/node": { - "version": "18.7.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz", - "integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg==", - "dev": true - }, - "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", - "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true - }, - "node_modules/aws-cdk": { - "version": "2.41.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.41.0.tgz", - "integrity": "sha512-Ubko4X8VcbaLzcXvCQZPKBtgwBq033m5sSWtdrbdlDp7s2J4uWtY6KdO1uYKAvHyWjm7kGVmDyL1Wj1zx3TPUg==", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 14.15.0" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/aws-cdk-lib": { - "version": "2.41.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.41.0.tgz", - "integrity": "sha512-wh6lDaarzb8B+43TMxEBg+yHcXU9omlUGJz9zSdgjrmeQWBV8SD0jIvrERhDFvQLmRY4Vzy7FXxkI0mU+adDHQ==", - "bundleDependencies": [ - "@balena/dockerignore", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "yaml" - ], - "dependencies": { - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^9.1.0", - "ignore": "^5.2.0", - "jsonschema": "^1.4.1", - "minimatch": "^3.1.2", - "punycode": "^2.1.1", - "semver": "^7.3.7", - "yaml": "1.10.2" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "constructs": "^10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/at-least-node": { - "version": "1.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "1.1.11", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/concat-map": { - "version": "0.0.1", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "9.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.10", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.4.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/lru-cache": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "3.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.1.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.3.7", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/constructs": { - "version": "10.1.107", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.1.107.tgz", - "integrity": "sha512-ewB60glRfBrzIlyujDJzIL/TWRhwwxtS579nOOqmvqaEKHtNgzHnDPBsq/vvvFpwZIRHs1ZUvtWPvxCg/2Ee6Q==", - "engines": { - "node": ">= 14.17.0" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true - }, - "node_modules/debug": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", - "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dotenv": { - "version": "16.0.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.2.tgz", - "integrity": "sha512-JvpYKUmzQhYoIFgK2MOnF3bciIZoItIIoryihy0rIA+H4Jy0FmgyKYAHCTN98P5ybGSJcIFbh6QKeJdtZd1qhA==", - "engines": { - "node": ">=12" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/mocked-env": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/mocked-env/-/mocked-env-1.3.5.tgz", - "integrity": "sha512-GyYY6ynVOdEoRlaGpaq8UYwdWkvrsU2xRme9B+WPSuJcNjh17+3QIxSYU6zwee0SbehhV6f06VZ4ahjG+9zdrA==", - "dev": true, - "dependencies": { - "check-more-types": "2.24.0", - "debug": "4.3.2", - "lazy-ass": "1.6.0", - "ramda": "0.27.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prettier": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", - "bin": { - "prettier": "bin-prettier.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/ramda": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", - "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==", - "dev": true - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ts-node": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", - "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", - "dev": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.3.tgz", - "integrity": "sha512-goMHfm00nWPa8UvR/CPSvykqf6dVV8x/dp0c5mFTMTIu0u0FlGWRioyy7Nn0PGAdHxpJZnuO/ut+PpQ8UiHAig==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "engines": { - "node": ">=6" - } - } - } -} diff --git a/cdk/package.json b/cdk/package.json deleted file mode 100644 index 67bc708..0000000 --- a/cdk/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "cdk", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "build": "tsc", - "clean": "rimraf node_modules cdk.out dist", - "deploy": "cdk deploy --require-approval never --profile $ACCOUNT_ID", - "lint": "prettier --check src/**/*.ts", - "lint-fix": "prettier --write src/**/*.ts", - "setup-env-vars": "export DATA_OPS_PEERING_CONNECTION_ID=$DATA_OPS_PEERING_CONNECTION_ID && export DATA_OPS_CIDR_BLOCK=$DATA_OPS_CIDR_BLOCK", - "synth": "ts-node ./src/synth.ts", - "verify": "npm run build && npm run lint && npm run synth" - }, - "author": "", - "license": "ISC", - "dependencies": { - "@aws-cdk/aws-codestar-alpha": "2.41.0-alpha.0", - "aws-cdk": "2.41.0", - "aws-cdk-lib": "2.41.0", - "constructs": "10.1.107", - "dotenv": "16.0.2", - "prettier": "2.7.1", - "rimraf": "^3.0.2" - }, - "devDependencies": { - "@types/node": "18.7.18", - "mocked-env": "1.3.5", - "ts-node": "10.9.1", - "typescript": "4.8.3" - }, - "prettier": { - "bracketSameLine": true, - "singleQuote": true, - "trailingComma": "none" - } -} diff --git a/cdk/src/CloudflarePublicHostnameStack.ts b/cdk/src/CloudflarePublicHostnameStack.ts deleted file mode 100644 index aa640c0..0000000 --- a/cdk/src/CloudflarePublicHostnameStack.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - App, - CfnOutput, - Duration, - Fn, - Stack, - StackProps, - aws_ec2, - aws_iam, - aws_kms, - aws_lambda -} from 'aws-cdk-lib'; -import { Effect } from 'aws-cdk-lib/aws-iam'; -import { Code, Runtime } from 'aws-cdk-lib/aws-lambda'; - -export default class CloudflarePublicHostnameStack extends Stack { - constructor(app: App, id: string, props: StackProps) { - super(app, id, props); - - const cloudflareLambda = new aws_lambda.Function(this, 'Function', { - code: Code.fromAsset(`${__dirname}/${process.env.ARTIFACT_PATH}`), - runtime: Runtime.JAVA_8, - functionName: 'cloudflare-public-hostname-lambda-Function-1KC7WIOVMTBR', - memorySize: 512, - timeout: Duration.seconds(60), - handler: 'com.dwolla.lambda.cloudflare.record.CloudflareDnsRecordHandler', - initialPolicy: [ - new aws_iam.PolicyStatement({ - effect: Effect.ALLOW, - actions: ['route53:GetHostedZone'], - resources: ['*'] - }) - ] - }); - - const keyAlias = 'alias/CloudflarePublicDnsRecordKey'; - - const kmsKey = new aws_kms.Key(this, 'Key', { - description: - 'Encryption key protecting secrets for the Cloudflare public record lambda', - enabled: true, - enableKeyRotation: true, - alias: keyAlias - }); - kmsKey.grant( - new aws_iam.ArnPrincipal( - Fn.sub('arn:aws:iam::${AWS::AccountId}:role/DataEncrypter') - ), - 'kms:Encrypt', - 'kms:ReEncrypt', - 'kms:DescribeKey' - ); - - kmsKey.grantDecrypt( - new aws_iam.ArnPrincipal(cloudflareLambda.role.roleArn) - ); - - new CfnOutput(this, 'CloudflarePublicHostnameLambda', { - description: 'ARN of the Lambda that interfaces with Cloudflare', - value: cloudflareLambda.functionName, - exportName: 'CloudflarePublicHostnameLambda' - }); - - new CfnOutput(this, 'CloudflarePublicHostnameKey', { - description: 'KMS Key Alias for Cloudflare public DNS record lambda', - value: keyAlias - }); - } -} diff --git a/cdk/src/index.ts b/cdk/src/index.ts deleted file mode 100644 index 8ab79dd..0000000 --- a/cdk/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { App } from 'aws-cdk-lib'; -import CloudflarePublicHostnameStack from './CloudflarePublicHostnameStack'; - -const app = new App(); - -new CloudflarePublicHostnameStack(app, 'cloudflare-public-hostname-lambda', {}); diff --git a/cdk/tsconfig.json b/cdk/tsconfig.json deleted file mode 100644 index 6892eb3..0000000 --- a/cdk/tsconfig.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "esModuleInterop": true, - "outDir": "dist", - "module": "commonJS", - "target": "ES6", - "typeRoots": [ - "./node_modules/@types" - ], - "types": [ - "node" - ] - }, - "exclude": [ - "cdk.out/**", - "cdk.json" - ] -} \ No newline at end of file diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..85619a1 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -o errexit +IFS=$'\n\t' + +# the .git folder and .gitignore file are not stashed by Jenkins, so we need to fetch them +git init +git remote add origin "$GIT_URL" +git fetch origin + +set +o nounset +if [ -z ${TAG_NAME+x} ]; then + TARGET_COMMIT="${GIT_COMMIT}" +else + TARGET_COMMIT="${TAG_NAME}" +fi +readonly TARGET_COMMIT + +# use `git checkout --force` here because we expect the working directory not to be +# empty at this point. Jenkins unstashed everything from the previous stage into the +# working directory; we want to keep the build artifacts (i.e. everything in the +# various target/ directories) but update the files committed to git to the version +# currently being built. +git checkout --force "${TARGET_COMMIT}" + +# nvm is a bash function, so fake command echoing for nvm commands to reduce noise +echo "+ . ${NVM_DIR}/nvm.sh --no-use" +. "${NVM_DIR}/nvm.sh" --no-use + +echo "+ nvm install" +nvm install + +echo "+ export SDKMAN_DIR=$HOME/.sdkman" +export SDKMAN_DIR="$HOME/.sdkman" + +# it seems like a bug in sdkman that this is needed 🤷 +echo "+ mkdir -p ${SDKMAN_DIR}/candidates/java/current/bin" +mkdir -p "${SDKMAN_DIR}/candidates/java/current/bin" + +echo "+ . ${SDKMAN_DIR}/bin/sdkman-init.sh" +. "${SDKMAN_DIR}/bin/sdkman-init.sh" + +echo "+ sdk env install use" +sdk env install use + +set -o xtrace -o nounset -o pipefail +npm install -g npm +npm install -g serverless + +sbt "show deploy Admin" diff --git a/project/CdkDeployPlugin.scala b/project/CdkDeployPlugin.scala new file mode 100644 index 0000000..da0fc31 --- /dev/null +++ b/project/CdkDeployPlugin.scala @@ -0,0 +1,63 @@ +import io.chrisdavenport.npmpackage.sbtplugin.NpmPackagePlugin.autoImport.* +import com.github.sbt.git.SbtGit.git +import feral.lambda.sbt.LambdaJSPlugin +import sbt.Keys.* +import sbt.internal.util.complete.DefaultParsers.* +import sbt.internal.util.complete.Parser +import sbt.{Def, settingKey, IO as _, *} + +object CdkDeployPlugin extends AutoPlugin { + object autoImport { + val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") + val deploy = inputKey[DeployOutcome]("deploy to AWS") + } + + import autoImport.* + + override def trigger: PluginTrigger = NoTrigger + + override def requires: Plugins = LambdaJSPlugin + + override lazy val projectSettings: Seq[Setting[?]] = Seq( + cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, + deploy := Def.inputTask { + import scala.sys.process.* + + val baseCommand = cdkDeployCommand.value + val deployProcess = Process( + baseCommand ++ Seq("--stage", Stage.parser.parsed.name), + Option((ThisBuild / baseDirectory).value), + "ARTIFACT_PATH" -> (Compile / npmPackageOutputDirectory).value.toString, + "VERSION" -> version.value, + "VCS_URL" -> (ThisBuild / homepage).value.get.toString, + ) + + if (taggedVersion.value.exists(_.toString == version.value)) { + if (deployProcess.! == 0) Success + else throw new IllegalStateException("Serverless returned a non-zero exit code. Please check the logs for more information.") + } else SkippedBecauseVersionIsNotLatestTag(version.value, taggedVersion.value) + }.evaluated + ) + + sealed abstract class Stage(val name: String) { + val parser: Parser[this.type] = (Space ~> token(this.toString)).map(_ => this) + } + + object Stage { + val parser: Parser[Stage] = + token(Stage.Admin.parser) | + token(Stage.Sandbox.parser) + + case object Admin extends Stage("admin") + case object Sandbox extends Stage("sandbox") + } + + private def taggedVersion: Def.Initialize[Option[Version]] = Def.setting { + git.gitCurrentTags.value.collect { case Version.Tag(v) => v }.sorted.lastOption + } + + sealed trait DeployOutcome // no failed outcome because we just throw an exception in that case + case object Success extends DeployOutcome + case class SkippedBecauseVersionIsNotLatestTag(version: String, taggedVersion: Option[Version]) extends DeployOutcome + +} diff --git a/project/CdkPlugin.scala b/project/CdkPlugin.scala deleted file mode 100644 index 7292a32..0000000 --- a/project/CdkPlugin.scala +++ /dev/null @@ -1,35 +0,0 @@ -// TODO -//import sbt.Keys.{baseDirectory, packageBin} -//import sbt.internal.util.complete.DefaultParsers._ -//import sbt.internal.util.complete.Parser -//import sbt.{Def, settingKey, IO => _, _} -// -//object CdkDeployPlugin extends AutoPlugin { -// object autoImport { -// val cdkDeployCommand = settingKey[Seq[String]]("cdk command to deploy the application") -// val deploy = taskKey[Int]("deploy to AWS") -// } -// -// import autoImport._ -// -// override def trigger: PluginTrigger = NoTrigger -// -// override def requires: Plugins = UniversalPlugin -// -// override lazy val projectSettings = Seq( -// cdkDeployCommand := "npm --prefix cdk run deploy --verbose".split(' ').toSeq, -// deploy := { -// import scala.sys.process._ -// -// val exitCode = Process( -// cdkDeployCommand.value, -// Option((ThisBuild / baseDirectory).value), -// "ARTIFACT_PATH" -> (Universal / packageBin).value.toString, -// ).! -// -// if (exitCode == 0) exitCode -// else throw new IllegalStateException("cdk returned a non-zero exit code. Please check the logs for more information.") -// } -// ) -// -//} diff --git a/project/LambdaStack.scala b/project/LambdaStack.scala new file mode 100644 index 0000000..3203037 --- /dev/null +++ b/project/LambdaStack.scala @@ -0,0 +1,95 @@ +import sbt.File +import software.amazon.awscdk.services.iam.{ArnPrincipal, PolicyStatement, ServicePrincipal} +import software.amazon.awscdk.{App, CfnOutput, Duration, Environment, Fn, Stack, StackProps} +import software.amazon.awscdk.services.lambda.* +import software.amazon.awscdk.services.kms.* +import software.constructs.Construct + +import scala.jdk.CollectionConverters.* +import scala.util.chaining.* + +object LambdaStack { + def apply(name: String, + handler: String, + assets: File, + ): App = { + val environment = Environment + .builder() + .account( + sys.env.getOrElse( + "CDK_DEFAULT_ACCOUNT", + throw new IllegalArgumentException("No default account found") + ) + ) + .region( + sys.env.getOrElse( + "CDK_DEFAULT_REGION", + throw new IllegalArgumentException("No default region found") + ) + ) + .build() + + new App() + .tap { + new LambdaStack(_, "cloudflare-public-hostname-lambda", StackProps.builder().env(environment).build())(name, handler, assets) + } + } + +} + +class LambdaStack(scope: Construct, + id: String, + props: StackProps) + (name: String, + handler: String, + assets: File, + ) + extends Stack(scope, id, props) { + + val function: Function = Function.Builder + .create(this, name) + .runtime(Runtime.NODEJS_22_X) + .timeout(Duration.seconds(60)) + .memorySize(512) + .handler(s"index.$handler") + .code(Code.fromAsset(assets.getPath)) + .initialPolicy( + List( + PolicyStatement.Builder.create() + .actions(List("route53:GetHostedZone").asJava) + .resources(List("*").asJava) + .build() + ).asJava + ) + .build() + + val keyAlias = "alias/CloudflarePublicDnsRecordKey" + + val kmsKey: Key = Key.Builder.create(this, "Key") + .description("Encryption key protecting secrets for the Cloudflare public record lambda") + .enabled(true) + .enableKeyRotation(true) + .alias(keyAlias) + .build() + + kmsKey.grant(new ArnPrincipal(Fn.sub("arn:aws:iam::$${AWS::AccountId}:role/DataEncrypter")), + "kms:Encrypt", + "kms:ReEncrypt", + "kms:DescribeKey", + ) + + kmsKey.grantDecrypt(new ArnPrincipal(function.getRole.getRoleArn)) + + CfnOutput.Builder + .create(this, "CloudflarePublicHostnameLambda") + .description("ARN of the Lambda that interfaces with Cloudflare") + .value(function.getFunctionName) + .exportName("CloudflarePublicHostnameLambda") + .build() + + CfnOutput.Builder + .create(this, "CloudflarePublicHostnameLambdaKey") + .description("KMS Key Alias for Cloudflare public DNS record lambda") + .value(keyAlias) + .build() +} diff --git a/project/Version.scala b/project/Version.scala new file mode 100644 index 0000000..91f9e7d --- /dev/null +++ b/project/Version.scala @@ -0,0 +1,82 @@ +/* + * Copyright 2022 Typelevel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* + * Originally obtained from + * https://raw.githubusercontent.com/typelevel/sbt-typelevel/main/kernel/src/main/scala/org/typelevel/sbt/kernel/V.scala + */ + +import scala.util.Try +import scala.util.matching.Regex + +final case class Version(major: Int, + minor: Int, + patch: Option[Int], + prerelease: Option[String] + ) extends Ordered[Version] { + + override def toString: String = + s"$major.$minor${patch.fold("")(p => s".$p")}${prerelease.fold("")(p => s"-$p")}" + + def isPrerelease: Boolean = prerelease.nonEmpty + + def isSameSeries(that: Version): Boolean = + this.major == that.major && this.minor == that.minor + + def mustBeBinCompatWith(that: Version): Boolean = + this >= that && !that.isPrerelease && this.major == that.major && + (major > 0 || (this.minor == that.minor && minor > 0)) + + def compare(that: Version): Int = { + val x = this.major.compare(that.major) + if (x != 0) return x + val y = this.minor.compare(that.minor) + if (y != 0) return y + (this.patch, that.patch) match { + case (None, None) => 0 + case (None, Some(_)) => 1 + case (Some(_), None) => -1 + case (Some(thisPatch), Some(thatPatch)) => + val z = thisPatch.compare(thatPatch) + if (z != 0) return z + (this.prerelease, that.prerelease) match { + case (None, None) => 0 + case (Some(_), None) => 1 + case (None, Some(_)) => -1 + case (Some(thisPrerelease), Some(thatPrerelease)) => + // TODO not great, but not everyone uses Ms and RCs + thisPrerelease.compare(thatPrerelease) + } + } + } + +} + +object Version { + val version: Regex = """^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-(.+))?$""".r + + def apply(v: String): Option[Version] = Version.unapply(v) + + def unapply(v: String): Option[Version] = v match { + case version(major, minor, patch, prerelease) => + Try(Version(major.toInt, minor.toInt, Option(patch).map(_.toInt), Option(prerelease))).toOption + case _ => None + } + + object Tag { + def unapply(v: String): Option[Version] = + if (v.startsWith("v")) Version.unapply(v.substring(1)) else None + } +} diff --git a/project/plugins.sbt b/project/plugins.sbt index 67398e0..5b3c061 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,10 @@ addSbtPlugin("org.typelevel" % "sbt-typelevel-settings" % "0.8.0") addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.0") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") -addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % "0.3.1") // in plugins.sbt +addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % "0.3.1") addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.42") +addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.12.0") + +libraryDependencies ++= Seq( + "software.amazon.awscdk" % "aws-cdk-lib" % "2.220.0", +) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index 033cb45..4e8c7df 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -1,7 +1,7 @@ package com.dwolla.lambda.cloudflare.record import _root_.io.circe.* -import _root_.io.circe.generic.auto.* +import _root_.io.circe.JsoniterScalaCodec.* import _root_.io.circe.syntax.* import cats.* import cats.data.* @@ -9,16 +9,21 @@ import cats.effect.std.Env import cats.effect.{Trace as _, *} import cats.mtl.Local import cats.syntax.all.* -import cats.tagless.aop.* import cats.tagless.Derive +import cats.tagless.aop.* import com.amazonaws.kms.{CiphertextType, KMS, PlaintextType} import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model import com.dwolla.cloudflare.domain.model.* import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists +import com.dwolla.lambda.cloudflare.record.NothingEncoder.* +import com.dwolla.tracing.LowPriorityTraceableValueInstances.* +import com.dwolla.tracing.syntax.* import feral.lambda.cloudformation.{CloudFormationCustomResource, CloudFormationCustomResourceRequest, HandlerResponse} -import feral.lambda.{IOLambda, Invocation, KernelSource, TracedHandler, cloudformation} -import fs2.io.net.Network +import feral.lambda.* import fs2.Stream +import fs2.io.compression.* +import fs2.io.net.Network import mouse.all.* import natchez.* import natchez.http4s.* @@ -32,11 +37,7 @@ import org.typelevel.log4cats.console.* import org.typelevel.log4cats.{Logger, LoggerFactory} import smithy4s.aws.kernel.AwsRegion import smithy4s.aws.{AwsClient, AwsEnvironment} -import _root_.io.circe.JsoniterScalaCodec.* import smithy4s.json.Json.* -import NothingEncoder.* -import com.dwolla.tracing.syntax.* -import com.dwolla.tracing.LowPriorityTraceableValueInstances.* import scala.util.control.NoStackTrace @@ -64,12 +65,13 @@ case class NoPlaintextForCiphertext(ciphertext: CiphertextType) extends RuntimeException(s"KMS returned no plaintext for ciphertext input $ciphertext") with NoStackTrace -class CloudflareDnsRecordHandler[F[_] : Concurrent : LoggerFactory : NonEmptyParallel : Trace](httpClient: Client[F], - kms: KMS[F], +@annotation.experimental +class CloudflareDnsRecordHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel, Trace}](httpClient: Client[F], + kms: KMS[F], ) extends CloudFormationCustomResource[F, DnsRecordWithCredentials, JsonObject] { private implicit val logger: Logger[F] = LoggerFactory[F].getLogger - private def constructCloudflareClient(input: DnsRecordWithCredentials): F[DnsRecordClient[F]] = + private def constructCloudflareClient(input: DnsRecordWithCredentials): F[DnsRecordClient[Stream[F, *]]] = for { (email, key) <- decryptSensitiveProperties(input) executor = new StreamingCloudflareApiExecutor[F](httpClient, CloudflareAuthorization(email.value.toUTF8String, key.value.toUTF8String)) @@ -107,7 +109,7 @@ object NothingEncoder { } object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceRequest[DnsRecordWithCredentials], Nothing] { - private def httpClient[F[_] : Async : Network : Trace]: Resource[F, Client[F]] = + private def httpClient[F[_] : {Async, Network, Trace}]: Resource[F, Client[F]] = EmberClientBuilder .default[F] .build @@ -115,24 +117,27 @@ object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceR .map(NatchezMiddleware.client(_)) override def handler: Resource[IO, Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => IO[Option[Nothing]]] = - for { + for xray <- XRay.entryPoint[IO]() - implicit0(logger: LoggerFactory[IO]) = ConsoleLoggerFactory.create[IO] - case implicit0(local: Local[IO, Span[IO]]) <- IO.local(Span.noop[IO]).toResource + given LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]).toResource client <- httpClient[IO] region <- Env[IO].get("AWS_REGION").liftEitherT(new RuntimeException("missing AWS_REGION environment variable")).map(AwsRegion(_)).rethrowT.toResource awsEnv <- AwsEnvironment.default(client, region) kms <- AwsClient(KMS, awsEnv) - } yield { - implicit val kernelSourceCloudFormationCustomResourceRequest: KernelSource[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] = KernelSource.emptyKernelSource + yield buildHandler(xray, client, kms) - val f: Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => IO[Option[Nothing]] = implicit inv => - TracedHandler(xray) { implicit trace => - CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms)) - } + def buildHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel}](entryPoint: EntryPoint[F], + client: Client[F], + kms: KMS[F], + ) + (using Local[F, Span[F]]): Invocation[F, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => F[Option[Nothing]] = + implicit inv => + given KernelSource[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] = KernelSource.emptyKernelSource + + TracedHandler(entryPoint): + CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms)) - f - } } trait UpdateCloudflare[F[_]] { @@ -142,15 +147,16 @@ trait UpdateCloudflare[F[_]] { def handleDelete(physicalResourceId: cloudformation.PhysicalResourceId): F[HandlerResponse[JsonObject]] } +@annotation.experimental object UpdateCloudflare { implicit val physicalResourceIdTraceableValue: TraceableValue[cloudformation.PhysicalResourceId] = TraceableValue[String].contramap(_.value) implicit val aspect: Aspect[UpdateCloudflare, TraceableValue, TraceableValue] = Derive.aspect - def apply[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]): UpdateCloudflare[F] = + def apply[F[_] : {Concurrent, Logger, Trace}](cloudflare: DnsRecordClient[Stream[F, *]]): UpdateCloudflare[F] = (new UpdateCloudflareImpl(cloudflare): UpdateCloudflare[F]).traceWithInputsAndOutputs } -class UpdateCloudflareImpl[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRecordClient[F]) extends UpdateCloudflare[F] { +class UpdateCloudflareImpl[F[_] : {Concurrent, Logger, Trace}](cloudflare: DnsRecordClient[Stream[F, *]]) extends UpdateCloudflare[F] { def handleCreateOrUpdate(unidentifiedDnsRecord: UnidentifiedDnsRecord, cloudformationProvidedPhysicalResourceId: Option[cloudformation.PhysicalResourceId]): F[HandlerResponse[JsonObject]] = { @@ -226,25 +232,38 @@ class UpdateCloudflareImpl[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRe // TODO add tracing private def createRecord: Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = Kleisli { unidentifiedDnsRecord => - cloudflare - .createDnsRecord(unidentifiedDnsRecord) - .recoverWith { - case RecordAlreadyExists => - cloudflare.getExistingDnsRecords(unidentifiedDnsRecord.name, Option(unidentifiedDnsRecord.content), Option(unidentifiedDnsRecord.recordType)) - } - .map(CreateOrUpdate.create) - .compile - .lastOrError + Trace[F].span("createRecord") { + cloudflare + .createDnsRecord(unidentifiedDnsRecord) + .compile + .last + .recoverWith { + case RecordAlreadyExists => + Trace[F].span("createRecord.RecordAlreadyExists") { + cloudflare + .getExistingDnsRecords(unidentifiedDnsRecord.name, Option(unidentifiedDnsRecord.content), Option(unidentifiedDnsRecord.recordType)) + .compile + .last + } + } + .liftOptionT + .getOrRaise(new NoSuchElementException(s"No DNS record was created for ${unidentifiedDnsRecord.name}")) + .map(CreateOrUpdate.create) + } } // TODO add tracing private def updateRecord(existingRecord: IdentifiedDnsRecord): Kleisli[F, UnidentifiedDnsRecord, CreateOrUpdate[IdentifiedDnsRecord]] = assertRecordTypeWillNotChange(existingRecord.recordType) - .map(_.identifyAs(existingRecord.physicalResourceId)) + .map(_.identifyAs(existingRecord.physicalResourceId.value)) .andThen { - cloudflare - .updateDnsRecord(_) - .map(CreateOrUpdate.update) + Stream.emit(_) + .unNone + .flatMap { + cloudflare + .updateDnsRecord(_) + .map(CreateOrUpdate.update) + } .compile .lastOrError } @@ -256,7 +275,7 @@ class UpdateCloudflareImpl[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRe for { providedId <- physicalResourceId discoveredId <- updateableRecord.map(_.physicalResourceId) - if providedId.value != discoveredId + if providedId.value != discoveredId.value } yield s"""The passed physical ID "$providedId" does not match the discovered physical ID "$discoveredId" for hostname "$hostname". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""" warning.traverse_(Logger[F].warn(_)) @@ -282,7 +301,7 @@ class UpdateCloudflareImpl[F[_] : Concurrent : Logger : Trace](cloudflare: DnsRe "oldDnsRecord" -> existingRecord.asJson, ) - HandlerResponse(cloudformation.PhysicalResourceId.unsafeApply(dnsRecord.physicalResourceId), data.some) + HandlerResponse(physicalResourceIdBijection.to(dnsRecord.physicalResourceId), data.some) } private def assertRecordTypeWillNotChange(existingRecordType: String): Kleisli[F, UnidentifiedDnsRecord, UnidentifiedDnsRecord] = diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala index ea0caf0..476c623 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/SchemaVisitorCirceCodec.scala @@ -31,8 +31,8 @@ class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Cod case DString(value) => value.asJson case DBoolean(value) => value.asJson case DNull => io.circe.Json.Null - case DArray(value) => io.circe.Json.fromValues(value.map(_.asJson(documentEncoder))) - case DObject(value) => io.circe.Json.fromFields(value.map { case (k, v) => k -> v.asJson(documentEncoder) }) + case DArray(value) => io.circe.Json.fromValues(value.map(_.asJson(using documentEncoder))) + case DObject(value) => io.circe.Json.fromFields(value.map { case (k, v) => k -> v.asJson(using documentEncoder) }) } private implicit def documentDecoder: Decoder[Document] = Decoder.instance { c => c.value.foldWith(DocumentFolder).toRight(DecodingFailure("Could not decode document", c.history)) @@ -156,7 +156,7 @@ class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Cod .traverse { case (df, _) => val label = df.field.label val sub = cursor.downField(label) - sub.as(df.dec) + sub.as(using df.dec) } .map(vec => make(vec.toIndexedSeq)) } @@ -191,7 +191,7 @@ class SchemaVisitorCirceCodec(override protected val cache: CompilationCache[Cod alternatives.find(_.label == label).toRight(DecodingFailure(s"Unknown union alternative: $label", c.history)).flatMap { a0 => val a = a0.asInstanceOf[Alt[U, Any]] val decAny = SchemaVisitorCirceCodec.fromSchema(a.schema, cache).asInstanceOf[Decoder[Any]] - c.downField(label).as(decAny).map(v => a.inject(v)) + c.downField(label).as(using decAny).map(v => a.inject(v)) } case Nil => Left(DecodingFailure("empty object for union", c.history)) case _ => Left(DecodingFailure("expected single-field object for union", c.history)) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala index 4f635f7..161de6d 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/package.scala @@ -1,14 +1,20 @@ package com.dwolla.lambda.cloudflare +import cats.syntax.all.* +import com.dwolla.cloudflare.domain.model import com.dwolla.cloudflare.domain.model.UnidentifiedDnsRecord -import io.circe._ -import shapeless.tag.@@ -import cats.syntax.contravariant._ +import feral.lambda.cloudformation +import io.circe.* +import smithy4s.Bijection package object record { - implicit def TaggedStringEncoder[B]: Encoder[String @@ B] = Encoder[String].narrow + given physicalResourceIdBijection: Bijection[model.PhysicalResourceId, cloudformation.PhysicalResourceId] = + Bijection[model.PhysicalResourceId, cloudformation.PhysicalResourceId]( + model.PhysicalResourceId.codec.extract.map(cloudformation.PhysicalResourceId.unsafeApply), + cloudformation.PhysicalResourceId.codec.extract.map(model.PhysicalResourceId(_)), + ) - implicit val decodeUnidentifiedDnsRecord: Decoder[UnidentifiedDnsRecord] = (c: HCursor) => + given Decoder[UnidentifiedDnsRecord] = (c: HCursor) => for { name <- c.downField("Name").as[String] content <- c.downField("Content").as[String] diff --git a/src/main/scala/io/circe/JsoniterScalaCodec.scala b/src/main/scala/io/circe/JsoniterScalaCodec.scala index 6cdd0f9..24732a0 100644 --- a/src/main/scala/io/circe/JsoniterScalaCodec.scala +++ b/src/main/scala/io/circe/JsoniterScalaCodec.scala @@ -16,7 +16,7 @@ object JsoniterScalaCodec { def fromJsoniter[A](implicit jc: JsonValueCodec[A]): Codec[A] = { val enc: Encoder[A] = Encoder.instance { a => // Use Jsoniter to serialize, then parse into Circe Json - val s = writeToString(a)(jc) + val s = writeToString(a)(using jc) io.circe.parser.parse(s) match { case Right(json) => json case Left(err) => @@ -29,7 +29,7 @@ object JsoniterScalaCodec { // Render the incoming Circe Json to a compact string, then read with Jsoniter val jsonStr = Printer.noSpaces.print(c.value) try { - Right(readFromString[A](jsonStr)(jc)) + Right(readFromString[A](jsonStr)(using jc)) } catch { case e: JsonReaderException => Left(DecodingFailure(e.getMessage, c.history)) case e: Throwable => Left(DecodingFailure(e.getMessage, c.history)) diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala index 6610c09..b7c8a21 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala @@ -1,636 +1,863 @@ package com.dwolla.lambda.cloudflare.record -import _root_.fs2._ -import cats.effect._ -import com.amazonaws.services.kms.model.AWSKMSException -import com.dwolla.cloudflare._ -import com.dwolla.cloudflare.domain.model._ -import com.dwolla.lambda.cloudformation._ -import com.dwolla.testutils.exceptions.NoStackTraceException -import _root_.io.circe._ -import _root_.io.circe.generic.auto._ -import _root_.io.circe.syntax._ +import _root_.fs2.* +import _root_.io.circe.* +import _root_.io.circe.generic.auto.* +import _root_.io.circe.syntax.* +import _root_.io.circe.literal.* +import cats.* +import cats.data.* +import cats.syntax.all.* +import cats.effect.* +import cats.effect.std.* +import cats.mtl.* +import com.amazonaws.kms.* +import com.amazonaws.kms.KMSGen.DecryptError +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model +import com.dwolla.cloudflare.domain.model.* +import com.dwolla.cloudflare.domain.dto.* +import com.dwolla.cloudflare.domain.dto.dns.* import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists -import org.specs2.concurrent.ExecutionEnv -import org.specs2.mock.Mockito -import org.specs2.mutable.Specification -import com.dwolla.fs2aws.kms._ -import com.dwolla.lambda.cloudflare.record.UpdateCloudflare.DnsRecordTypeChange - -class UpdateCloudflareSpec(implicit ee: ExecutionEnv) extends Specification with Mockito { - - private val tagPhysicalResourceId = shapeless.tag[PhysicalResourceIdTag][String] _ - private val tagZoneId = shapeless.tag[ZoneIdTag][String] _ - private val tagResourceId = shapeless.tag[ResourceIdTag][String] _ - - "CloudflareDnsRecordHandler" should { - "propagate exceptions thrown by the KMS decrypter" >> { - val kmsExceptionMessage = "The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" - - val mockKms = new ExceptionRaisingDecrypter[IO](new AWSKMSException(kmsExceptionMessage)) - val handler = new CloudflareDnsRecordHandler(Stream.empty, Stream.emit(mockKms)) - - val request = buildRequest( - requestType = "update", - physicalResourceId = Option("different-physical-id"), - resourceProperties = Option(Map( - "Name" -> Json.fromString("example.dwolla.com"), - "Content" -> Json.fromString("new-example.dwollalabs.com"), - "Type" -> Json.fromString("CNAME"), - "TTL" -> Json.fromString("42"), - "Proxied" -> Json.fromString("true"), - "CloudflareEmail" -> Json.fromString("cloudflare-account-email@dwollalabs.com"), - "CloudflareKey" -> Json.fromString("fake-key") - )) - ) - - val output = handler.handleRequest(request).unsafeToFuture() - - output must throwA[AWSKMSException].like { case ex => ex.getMessage must startWith(kmsExceptionMessage) }.await - } +import com.dwolla.lambda.cloudflare.record.DnsRecordTypeChange +import feral.lambda.* +import feral.lambda.cloudformation +import feral.lambda.cloudformation.{PhysicalResourceId as _, *} +import org.typelevel.scalaccompat.annotation.targetName3 +import smithy4s.* +import smithy4s.kinds.* +import org.http4s.client.Client +import org.http4s.{BuildInfo as _, *} +import org.http4s.syntax.all.* +import org.http4s.dsl.* +import org.http4s.circe.* +import org.http4s.server.* +import org.http4s.server.middleware.authentication.challenged +import org.http4s.dsl.io.* +import munit.* +import natchez.* +import natchez.http4s.* +import natchez.mtl.* +import natchez.mtl.http4s.syntax.entrypoint.* +import org.typelevel.log4cats.LoggerFactory +import org.typelevel.log4cats.console.ConsoleLoggerFactory +import _root_.scodec.bits.* +import CloudflareQueryParams.* +import com.dwolla.tracing.LowPriorityTraceableValueInstances.* + +import scala.concurrent.duration.* + +class ResponseCapturingHttpApp[F[_] : Async](queue: Queue[F, Either[Json, CloudFormationCustomResourceResponse]]) { + private val dsl: Http4sDsl[F] = Http4sDsl[F] + import dsl.* + + private given [A: Decoder, B: Decoder]: Decoder[Either[A, B]] = + Decoder[B].map(_.asRight) or Decoder[A].map(_.asLeft) + + private given [A: Decoder]: EntityDecoder[F, A] = jsonOf + + def responses: QueueSource[F, Either[Json, CloudFormationCustomResourceResponse]] = queue + + def routes: HttpRoutes[F] = HttpRoutes.of[F] { + case req@PUT -> Root / "cloudformation-response" => + for + json <- req.as[Either[Json, CloudFormationCustomResourceResponse]] + _ <- queue.offer(json) + resp <- Ok() + yield resp } - "UpdateCloudflare create" should { - - "create specified CNAME record" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } + def client: Client[F] = Client.fromHttpApp(routes.orNotFound) - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("created" -> expectedRecord.asJson) - handlerResponse.data must havePair("updated" -> None.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) - }.await - } + val uri: Uri = uri"/cloudformation-response" +} - "log failure and close the clients if creation fails" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) +object ResponseCapturingHttpApp { + def apply[F[_] : Async]: F[ResponseCapturingHttpApp[F]] = + Queue.unbounded[F, Either[Json, CloudFormationCustomResourceResponse]].map(new ResponseCapturingHttpApp(_)) +} - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.raiseError(NoStackTraceException) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +object CloudflareQueryParams { + object domainNameQueryParamMatcher extends QueryParamDecoderMatcher[String]("name") + object statusQueryParamMatcher extends QueryParamDecoderMatcher[String]("status") + object typeQueryParamMatcher extends QueryParamDecoderMatcher[String]("type") - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } +} - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +object CloudflareAuth { + type CloudflareAuthenticator[F[_], A] = CloudflareAuthorization => F[Option[A]] - output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await - } + private def validate[F[_]](expected: CloudflareAuthorization): CloudflareAuthenticator[F, CloudflareAuthorization] = + Option(_).filter(_ == expected).pure[F] - "propagate exception if fetching existing records fails" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) + def apply[F[_]: Sync, A]: AuthMiddleware[F, A] = + challenged(challenge("Cloudflare", validate)) - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = - Stream.raiseError(NoStackTraceException) + def challenge[F[_]: Applicative](expected: CloudflareAuthorization): Kleisli[F, Request[F], Either[Challenge, AuthedRequest[F, CloudflareAuthorization]]] = + Kleisli { req => + validatePassword(validate, req).map { + case Some(authInfo) => + Right(AuthedRequest(authInfo, req)) + case None => + Left(Challenge("Cloudflare", realm, authParams)) } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) - - output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await } - "create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation" >> { - val providedPhysicalId = Option("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, providedPhysicalId) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) - }.await + private def validatePassword[F[_] : Applicative](expected: CloudflareAuthorization, + req: Request[F]): F[Option[CloudflareAuthorization]] = + (req.headers.get[`X-Auth-Email`], req.headers.get[`X-Auth-Key`]) match { + case Some((Some(email), Some(key))) if email == expected.email && key == expected.key => + expected.some.pure[F] + case _ => + none.pure[F] } - "create a DNS record that isn't an CNAME even if record(s) with the same name already exist" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } +} - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +@annotation.experimental +class UpdateCloudflareSpec extends CatsEffectSuite { - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("created" -> expectedRecord.asJson) - handlerResponse.data must havePair("updated" -> None.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) - }.await - } + given [F[_] : Sync]: LoggerFactory[F] = ConsoleLoggerFactory.create - "pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists" >> { - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val expectedRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val existingRecord = expectedRecord.copy( - physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), - resourceId = tagResourceId("different-record"), - content = "different-content", - priority = Option(0), - ) + private val tagPhysicalResourceId: String => model.PhysicalResourceId = PhysicalResourceId(_) + private val tagZoneId: String => model.ZoneId = ZoneId(_) + private val tagResourceId: String => model.ResourceId = ResourceId(_) - val fakeCloudflareClient = new FakeDnsRecordClient { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + given Show[InMemory.Lineage] with + def show(lineage: InMemory.Lineage): String = lineage match + case InMemory.Lineage.Root(name) => name + case InMemory.Lineage.Child(parent, name) => s"${parent.show}/${name.show}" - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } + given Show[Kernel] with + def show(kernel: Kernel): String = kernel.toHeaders.map { case (k, v) => s"$k: $v" }.mkString(", ") - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) + given Show[Span.Options] with + def show(options: Span.Options): String = options.toString - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== existingRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" -> existingRecord.asJson) - handlerResponse.data must havePair("created" -> existingRecord.asJson) - handlerResponse.data must havePair("updated" -> None.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) - }.await + given Show[List[(String, TraceValue)]] with + def show(fields: List[(String, TraceValue)]): String = fields.map { + case (k, TraceValue.StringValue(v)) => s"$k: $v" + case (k, TraceValue.BooleanValue(v)) => s"$k: $v" + case (k, TraceValue.NumberValue(v)) => s"$k: $v" } + .mkString(", ") + + given Show[InMemory.NatchezCommand] with + def show(command: InMemory.NatchezCommand): String = command match + case InMemory.NatchezCommand.AskKernel(kernel) => s"AskKernel(${kernel.show})" + case InMemory.NatchezCommand.AskSpanId => "AskSpanId" + case InMemory.NatchezCommand.AskTraceId => "AskTraceId" + case InMemory.NatchezCommand.AskTraceUri => "AskTraceUri" + case InMemory.NatchezCommand.Put(fields) => s"Put(${fields.show})" + case InMemory.NatchezCommand.CreateSpan(name, kernel, options) => s"CreateSpan($name, ${kernel.show}, ${options.show})" + case InMemory.NatchezCommand.ReleaseSpan(name) => s"ReleaseSpan($name)" + case InMemory.NatchezCommand.AttachError(err, fields) => s"AttachError(${Option(err.getMessage).getOrElse(err.toString)} (${err.getStackTrace.headOption.map(_.toString).getOrElse("no stack trace")}), ${fields.show})" + case InMemory.NatchezCommand.LogEvent(event) => s"LogEvent($event)" + case InMemory.NatchezCommand.LogFields(fields) => s"LogFields(${fields.show})" + case InMemory.NatchezCommand.CreateRootSpan(name, kernel, options) => s"CreateRootSpan($name, ${kernel.show}, ${options.show})" + case InMemory.NatchezCommand.ReleaseRootSpan(name) => s"ReleaseRootSpan($name)" + + /** + * This object provides functionality to generate an identifier from a given name + * and to extract the original name from a given identifier. + * + * The `apply` method is responsible for converting a string input (`name`) + * into its hexadecimal representation. This serves as a form of identifier or key. + * + * The `unapply` method performs the reverse operation, taking a hexadecimal identifier + * and decoding it back into its original string, if possible. + */ + private object idFromName { + def apply(name: String): String = + BitVector + .encodeUtf8(name) + .map(_.toHex) + .toOption + .get + + def unapply(id: String): Option[String] = + BitVector + .fromHex(id) + .flatMap(_.decodeUtf8.toOption) } - "CloudflareDnsRecordHandler update" should { - "update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true), - priority = Option(10), - ) - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = None, - priority = Option(10), - ) - - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getByUri(uri: String) = - if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) - }.await - - // TODO deal with logging -// there were noCallsTo(mockLogger) - } - - "update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true), - ) - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) - }.await - -// there was one(mockLogger).warn(startsWith("""Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com"""")) - } - - "update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - val inputRecord = existingRecord.unidentify - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = - if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) - else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== expectedRecord.physicalResourceId - handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) - handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) - }.await - - // TODO deal with logging -// there was one(mockLogger).warn(startsWith( -// """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com".""")) - } - - "refuse to change the record type if the input type is CNAME" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "A", - ttl = Option(42), - proxied = Option(true) - ) - val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) => - existingRecordType must_== "A" - newRecordType must_== "CNAME" - } - } - - "refuse to change the record type if the input type is not CNAME" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "MX", - ttl = Option(42), - proxied = Option(true) - ) - val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(existingRecord) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { - case DnsRecordTypeChange(existingRecordType, newRecordType) => - existingRecordType must_== "MX" - newRecordType must_== "TXT" - } - } - - "propagate the failure exception if update fails" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val existingRecord = IdentifiedDnsRecord( - physicalResourceId = tagPhysicalResourceId(physicalResourceId), - zoneId = tagZoneId("fake-zone-id"), - resourceId = tagResourceId("fake-resource-id"), - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) + test("CloudflareDnsRecordHandler should propagate exceptions thrown by the KMS decrypter") { + for + given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]) + entryPoint <- InMemory.EntryPoint.create[IO] + fakeCloudFormation <- ResponseCapturingHttpApp[IO] + kmsExceptionMessage = "The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" + mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.raiseError(KeyUnavailableException(ErrorMessageType(kmsExceptionMessage).some))) + handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, fakeCloudFormation.client, mockKms) + request <- buildRequest( + requestType = CloudFormationRequestType.UpdateRequest, + physicalResourceId = cloudformation.PhysicalResourceId("different-physical-id"), + responseUri = fakeCloudFormation.uri, + resourceProperties = Map( + "Name" -> Json.fromString("example.dwolla.com"), + "Content" -> Json.fromString("new-example.dwollalabs.com"), + "Type" -> Json.fromString("CNAME"), + "TTL" -> Json.fromString("42"), + "Proxied" -> Json.fromString("true"), + "CloudflareEmail" -> Json.fromString(utf8Bytes"cloudflare-account-email@dwollalabs.com".toBase64), + "CloudflareKey" -> Json.fromString(utf8Bytes"fake-key".toBase64) + ).some, ) - - val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") - - val fakeCloudflareClient = new FakeDnsRecordClient { - override def updateDnsRecord(record: IdentifiedDnsRecord) = Stream.raiseError(NoStackTraceException) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]) = - if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) - else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) - } - - val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) + output <- handler(request) + _ <- entryPoint.ref.get.map(_.mkString_("\n")).flatMap(IO.println) + response <- fakeCloudFormation.responses.take + yield { + assertEquals(output, None) + assertEquals(response, CloudFormationCustomResourceResponse( + Status = RequestResponseStatus.Failed, + Reason = kmsExceptionMessage.some, + PhysicalResourceId = cloudformation.PhysicalResourceId("different-physical-id"), + StackId = StackId(""), + RequestId = RequestId(""), + LogicalResourceId = LogicalResourceId(""), + Data = + json"""{ + "StackTrace": [ + "com.amazonaws.kms.KeyUnavailableException: The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" + ] + }""", + ).asRight) } } - "CloudflareDnsRecordHandler delete" should { - "delete a DNS record if requested" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - val existingRecord = inputRecord.identifyAs(physicalResourceId) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(existingRecord) - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.emit(physicalResourceId).map(tagPhysicalResourceId) + test("UpdateCloudflare create should create specified CNAME record") { + for + given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]) + entryPoint <- InMemory.EntryPoint.create[IO] + fakeCloudFormation <- ResponseCapturingHttpApp[IO] + cloudflare = HttpRoutes.of[IO] { + case req@GET -> Root / "client" / "v4" / "zones" :? domainNameQueryParamMatcher(name) +& statusQueryParamMatcher("active") => + Ok(ResponseDTO( + success = true, + result = ZoneDTO(id = idFromName(name).some, name = name), + errors = None, + messages = None, + ).asJson) + + case GET -> Root / "client" / "v4" / "zones" / idFromName(_) / "dns_records" :? domainNameQueryParamMatcher(_) +& typeQueryParamMatcher("CNAME") => + Ok(PagedResponseDTO( + result = List.empty[DnsRecordDTO], + success = true, + errors = None, + messages = None, + result_info = ResultInfoDTO( + page = 1, + per_page = 10, + count = 1, + total_pages = 1, + total_count = 1, + ).some + ).asJson) + + case req@POST -> Root / "client" / "v4" / "zones" / idFromName("dwolla.com") / "dns_records" => + given [A: Decoder]: EntityDecoder[IO, A] = jsonOf[IO, A] + + for + dto <- req.as[DnsRecordDTO] + _ <- Trace[IO].put("received" -> dto) + resp <- Ok(ResponseDTO( + result = dto.copy(id = idFromName(dto.name).some).some, + success = true, + errors = None, + messages = None, + ).asJson) + yield resp } - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must havePair("deletedRecordId" -> physicalResourceId.asJson) - }.await - } - - "delete is successful even if the physical ID passed by CloudFormation doesn't exist" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) - ) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.empty - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) + client = NatchezMiddleware.client(Client.fromHttpApp(entryPoint.liftRoutes((cloudflare) <+> fakeCloudFormation.routes).orNotFound)) + + mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.stub) { + override def decrypt(ciphertextBlob: CiphertextType, + encryptionContext: Option[Map[EncryptionContextKey, EncryptionContextValue]], + grantTokens: Option[List[GrantTokenType]], + keyId: Option[KeyIdType], + encryptionAlgorithm: Option[EncryptionAlgorithmSpec], + recipient: Option[RecipientInfo], + dryRun: Option[NullableBooleanType]): IO[DecryptResponse] = + DecryptResponse(plaintext = PlaintextType(ciphertextBlob.value).some).pure[IO] } - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { - case List(handlerResponse) => - handlerResponse.physicalId must_== physicalResourceId - handlerResponse.data must not(havePair("deletedRecordId" -> physicalResourceId)) - }.await - - // TODO deal with logging -// there was one(mockLogger).error("The record could not be deleted because it did not exist; nonetheless, responding with Success!", -// DnsRecordIdDoesNotExistException("fake-url")) - } - - "log failure and close the clients if delete fails" >> { - val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" - val inputRecord = UnidentifiedDnsRecord( - name = "example.dwolla.com", - content = "example.dwollalabs.com", - recordType = "CNAME", - ttl = Option(42), - proxied = Option(true) + handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, client, mockKms) + request <- buildRequest( + requestType = CloudFormationRequestType.CreateRequest, + physicalResourceId = None, + responseUri = fakeCloudFormation.uri, + resourceProperties = Map( + "Name" -> Json.fromString("example.dwolla.com"), + "Content" -> Json.fromString("example.dwollalabs.com"), + "Type" -> Json.fromString("CNAME"), + "TTL" -> Json.fromString("42"), + "Proxied" -> Json.fromString("true"), + "CloudflareEmail" -> Json.fromString(utf8Bytes"cloudflare-account-email@dwollalabs.com".toBase64), + "CloudflareKey" -> Json.fromString(utf8Bytes"fake-key".toBase64) + ).some, ) - - val fakeDnsRecordClient = new FakeDnsRecordClient { - override def getByUri(uri: String) = - Stream.emit(inputRecord.identifyAs(physicalResourceId)) - - override def deleteDnsRecord(physicalResourceId: String) = - Stream.raiseError(NoStackTraceException) - } - - val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) - - output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) - } + output <- handler(request) + captured <- fakeCloudFormation.responses.take + yield + val expectedPhysicalResourceId = s"https://api.cloudflare.com/client/v4/zones/${idFromName("dwolla.com")}/dns_records/${idFromName("example.dwolla.com")}" + val expected = + json"""{ + "physicalResourceId" : $expectedPhysicalResourceId, + "zoneId" : ${idFromName("dwolla.com")}, + "resourceId" : ${idFromName("example.dwolla.com")}, + "name" : "example.dwolla.com", + "content" : "example.dwollalabs.com", + "recordType" : "CNAME", + "ttl" : 42, + "proxied" : true + }""" + assertEquals(output, None) + assertEquals(captured, CloudFormationCustomResourceResponse( + Status = RequestResponseStatus.Success, + Reason = None, + PhysicalResourceId = cloudformation.PhysicalResourceId(expectedPhysicalResourceId), + StackId = StackId(""), + RequestId = RequestId(""), + LogicalResourceId = LogicalResourceId(""), + Data = + json"""{ + "dnsRecord": $expected, + "created": $expected + }""", + ).asRight) } - "Exceptions" >> { - "DnsRecordTypeChange" should { - "mention the existing and new record types" >> { - DnsRecordTypeChange("existing", "new") must beLikeA[RuntimeException] { - case ex => ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" - } - } - } +// test("UpdateCloudflare create should log failure and close the clients if creation fails") { +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord) Stream.raiseError(NoStackTraceException) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +// +// output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await +// } +// +// test("UpdateCloudflare create should propagate exception if fetching existing records fails") { +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// Stream.raiseError(NoStackTraceException) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +// +// output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await +// } +// +// test("UpdateCloudflare create should create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation") { +// val providedPhysicalId = Option("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// val expectedRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord) Stream.emit(expectedRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, providedPhysicalId) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== expectedRecord.physicalResourceId +// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) +// }.await +// } +// +// test("UpdateCloudflare create should create a DNS record that isn't an CNAME even if record(s) with the same name already exist") { +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true), +// priority = Option(10), +// ) +// val expectedRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true), +// priority = Option(10), +// ) +// +// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord) Stream.emit(expectedRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) +// handlerResponse.data must havePair("created" -> expectedRecord.asJson) +// handlerResponse.data must havePair("updated" -> None.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) +// }.await +// } +// +// test("UpdateCloudflare create should pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists") { +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true), +// priority = Option(10), +// ) +// val expectedRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true), +// priority = Option(10), +// ) +// val existingRecord = expectedRecord.copy( +// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), +// resourceId = tagResourceId("different-record"), +// content = "different-content", +// priority = Option(0), +// ) +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == existingRecord.name) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== existingRecord.physicalResourceId +// handlerResponse.data must havePair("dnsRecord" -> existingRecord.asJson) +// handlerResponse.data must havePair("created" -> existingRecord.asJson) +// handlerResponse.data must havePair("updated" -> None.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) +// }.await +// } +// +// test("CloudflareDnsRecordHandler update should update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true), +// priority = Option(10), +// ) +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = None, +// priority = Option(10), +// ) +// +// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = +// if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== expectedRecord.physicalResourceId +// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) +// }.await +// +// // TODO deal with logging +//// there were noCallsTo(mockLogger) +// } +// +// test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true), +// ) +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == existingRecord.name) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, Option(physicalResourceId)) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== expectedRecord.physicalResourceId +// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) +// }.await +// +//// there was one(mockLogger).warn(startsWith("""Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com"""")) +// } +// +// test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") +// val inputRecord = existingRecord.unidentify +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = +// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == existingRecord.name) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== expectedRecord.physicalResourceId +// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) +// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) +// }.await +// +// // TODO deal with logging +//// there was one(mockLogger).warn(startsWith( +//// """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com".""")) +// } +// +// test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is CNAME") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "A", +// ttl = Option(42), +// proxied = Option(true) +// ) +// val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") +// +// val fakeCloudflareClient = new FakeDnsRecordClient { +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) +// +// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { +// case DnsRecordTypeChange(existingRecordType, newRecordType) => +// existingRecordType must_== "A" +// newRecordType must_== "CNAME" +// } +// } +// +// test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is not CNAME") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "MX", +// ttl = Option(42), +// proxied = Option(true) +// ) +// val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") +// +// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = +// Stream.emit(existingRecord) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) +// +// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { +// case DnsRecordTypeChange(existingRecordType, newRecordType) => +// existingRecordType must_== "MX" +// newRecordType must_== "TXT" +// } +// } +// +// test("CloudflareDnsRecordHandler update should propagate the failure exception if update fails") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val existingRecord = IdentifiedDnsRecord( +// physicalResourceId = tagPhysicalResourceId(physicalResourceId), +// zoneId = tagZoneId("fake-zone-id"), +// resourceId = tagResourceId("fake-resource-id"), +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") +// +// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(NoStackTraceException) +// +// override def getExistingDnsRecords(name: String, +// content: Option[String], +// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = +// if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) +// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) +// } +// +// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) +// +// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) +// } +// +// test("CloudflareDnsRecordHandler delete should delete a DNS record if requested") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// val existingRecord = inputRecord.identifyAs(physicalResourceId) +// +// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = +// Stream.emit(existingRecord) +// +// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = +// Stream.emit(physicalResourceId).map(tagPhysicalResourceId) +// } +// +// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== physicalResourceId +// handlerResponse.data must havePair("deletedRecordId" -> physicalResourceId.asJson) +// }.await +// } +// +// test("CloudflareDnsRecordHandler delete should delete is successful even if the physical ID passed by CloudFormation doesn't exist") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = +// Stream.empty +// +// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = +// Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) +// } +// +// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) +// +// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { +// case List(handlerResponse) => +// handlerResponse.physicalId must_== physicalResourceId +// handlerResponse.data must not(havePair("deletedRecordId" -> physicalResourceId)) +// }.await +// +// // TODO deal with logging +//// there was one(mockLogger).error("The record could not be deleted because it did not exist; nonetheless, responding with Success!", +//// DnsRecordIdDoesNotExistException("fake-url")) +// } +// +// test("CloudflareDnsRecordHandler delete should log failure and close the clients if delete fails") { +// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" +// val inputRecord = UnidentifiedDnsRecord( +// name = "example.dwolla.com", +// content = "example.dwollalabs.com", +// recordType = "CNAME", +// ttl = Option(42), +// proxied = Option(true) +// ) +// +// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { +// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = +// Stream.emit(inputRecord.identifyAs(physicalResourceId)) +// +// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = +// Stream.raiseError(NoStackTraceException) +// } +// +// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) +// +// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) +// } +// +// test("DnsRecordTypeChange should mention the existing and new record types") { +// DnsRecordTypeChange("existing", "new") must beLikeA[RuntimeException] { +// case ex => ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" +// } +// } + + private def buildRequest(requestType: CloudFormationRequestType, + physicalResourceId: Option[cloudformation.PhysicalResourceId], + resourceProperties: Option[Map[String, Json]], + responseUri: Uri, + ): IO[Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]]] = { + json"""{ + "RequestType": $requestType, + "ResponseURL": $responseUri, + "StackId": "", + "RequestId": "", + "ResourceType": "", + "LogicalResourceId": "", + "PhysicalResourceId": $physicalResourceId, + "ResourceProperties": $resourceProperties, + "OldResourceProperties": null + }""" + .as[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] + .map(Invocation.pure(_, Context( + functionName = "CloudflareDnsRecordHandler", + functionVersion = BuildInfo.version, + invokedFunctionArn = "", + memoryLimitInMB = 512, // TODO get from buildinfo + awsRequestId = "", + logGroupName = "", + logStreamName = "", + identity = None, + clientContext = None, + remainingTime = 60.seconds.pure[IO] + ))) + .liftTo[IO] } - private def buildRequest(requestType: String, - physicalResourceId: Option[String], - resourceProperties: Option[Map[String, Json]]) = - CloudFormationCustomResourceRequest( - RequestType = requestType, - ResponseURL = "", - StackId = "", - RequestId = "", - ResourceType = "", - LogicalResourceId = "", - PhysicalResourceId = physicalResourceId, - ResourceProperties = resourceProperties, - OldResourceProperties = None - ) - } case class CustomNoStackTraceException(msg: String, ex: Throwable = null) extends RuntimeException(msg, ex, true, false) - -abstract class FakeDnsRecordClient extends DnsRecordClient[IO] { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def getById(zoneId: ZoneId, resourceId: ResourceId): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(new NotImplementedError()) - - override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = Stream.raiseError(new NotImplementedError()) -} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala new file mode 100644 index 0000000..5f3da09 --- /dev/null +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala @@ -0,0 +1,26 @@ +package com.dwolla.lambda.cloudflare.record + +import fs2.* +import cats.effect.* +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model.* +import org.typelevel.scalaccompat.annotation.targetName3 + +abstract class FakeDnsRecordClient extends DnsRecordClient[Stream[IO, *]] { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) + + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) + + override def getById(zoneId: ZoneId, resourceId: ResourceId): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = Stream.raiseError[IO](new NotImplementedError()) + + @targetName3("deleteDnsRecordNewtype") + final override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): Stream[IO, PhysicalResourceId] = deleteDnsRecord(physicalResourceId.value) + + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) +} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala new file mode 100644 index 0000000..496212b --- /dev/null +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala @@ -0,0 +1,192 @@ +package com.dwolla.lambda.cloudflare.record + +import cats.effect.* +import cats.syntax.all.* +import com.amazonaws.kms.* +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model.* +import fs2.Stream +import io.circe.* +import io.circe.syntax.* +import munit.{CatsEffectSuite, Compare} +import natchez.Trace +import org.http4s.client.Client +import org.typelevel.log4cats.console.ConsoleLoggerFactory +import org.typelevel.log4cats.{Logger, LoggerFactory} +import org.typelevel.scalaccompat.annotation.targetName3 +import smithy4s.{Bijection, Blob} + +@annotation.experimental +class UpdateCloudflareSuite extends CatsEffectSuite { + given [A, B](using Bijection[B, A], Compare[A, A]): Compare[A, B] = + (obtained: A, expected: B) => + summon[Compare[A, A]].isEqual(obtained, summon[Bijection[B, A]].to(expected)) + + test("CloudflareDnsRecordHandler propagates exceptions thrown by the KMS client (smithy4s)") { + val kmsErrorMessage = "The ciphertext refers to a KMS key you cannot access" + + val failingKms: KMS[IO] = new KMS[IO] { + def decrypt(ciphertextBlob: CiphertextType, + encryptionContext: Option[Map[EncryptionContextKey, EncryptionContextValue]], + grantTokens: Option[List[GrantTokenType]], + keyId: Option[KeyIdType], + encryptionAlgorithm: Option[EncryptionAlgorithmSpec], + recipient: Option[RecipientInfo], + dryRun: Option[NullableBooleanType] + ): IO[DecryptResponse] = + IO.raiseError(InvalidCiphertextException(Option(ErrorMessageType(kmsErrorMessage)))) + } + + // minimal Client that should never be used in this test (decrypt fails first) + val dummyClient: Client[IO] = Client[IO](_ => Resource.eval(IO.raiseError(new RuntimeException("HTTP should not be called")))) + + implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + + val handler = new CloudflareDnsRecordHandler[IO](dummyClient, failingKms) + + val input = DnsRecordWithCredentials( + dnsRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "new-example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ), + cloudflareEmail = CiphertextType(Blob("cloudflare-account-email@dwollalabs.com".getBytes("UTF-8"))), + cloudflareKey = CiphertextType(Blob("fake-key".getBytes("UTF-8"))) + ) + + interceptMessageIO[InvalidCiphertextException](kmsErrorMessage) { + handler.updateResource(input, feral.lambda.cloudformation.PhysicalResourceId.unsafeApply("different-physical-id")) + } + } + + test("create specified CNAME record") { + implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + implicit val logger: Logger[IO] = loggerFactory.getLogger + implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty + else Stream.raiseError[IO](new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + val output = UpdateCloudflare(fakeCloudflareClient).handleCreateOrUpdate(inputRecord, None) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId.value, "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + assertEquals(handlerResponse.data.get.apply("dnsRecord"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("created"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("updated"), Some(None.asJson)) + assertEquals(handlerResponse.data.get.apply("oldDnsRecord"), Some(None.asJson)) + } + } + } + + test("update a non-CNAME DNS record if it already exists, with physical ID from CloudFormation") { + implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + implicit val logger: Logger[IO] = loggerFactory.getLogger + implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = None, + priority = Option(10), + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeCloudflareClient = new FakeDnsRecordClient { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) + + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) + else Stream.raiseError[IO](new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) + } + + val output = UpdateCloudflare(fakeCloudflareClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId, expectedRecord.physicalResourceId) + assertEquals(handlerResponse.data.get.apply("dnsRecord"), Some(expectedRecord.asJson)) + assertEquals(handlerResponse.data.get.apply("oldDnsRecord"), Some(existingRecord.asJson)) + } + } + } + + test("delete a DNS record if requested") { + implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] + implicit val logger: Logger[IO] = loggerFactory.getLogger + implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val existingRecord = inputRecord.identifyAs(physicalResourceId) + + val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(existingRecord).unNone + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.emit(PhysicalResourceId(physicalResourceId)) + } + + val output = UpdateCloudflare(fakeDnsRecordClient).handleDelete(feral.lambda.cloudformation.PhysicalResourceId.unsafeApply(physicalResourceId)) + + output.flatMap { handlerResponse => + IO { + assertEquals(handlerResponse.physicalId.value, physicalResourceId) + assertEquals(handlerResponse.data.get.apply("deletedRecordId"), Some(physicalResourceId.asJson)) + } + } + } +} From 1f771458fdde40b416e22a3e5c6275d88e4b605b Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 16:14:13 -0500 Subject: [PATCH 07/11] wip, switching to simpler mocks of DnsRecordClient --- .../record/CloudflareDnsRecordHandler.scala | 11 ++- .../CloudflareDnsRecordHandlerSpec.scala | 92 ++++--------------- .../record/UpdateCloudflareSuite.scala | 2 +- 3 files changed, 27 insertions(+), 78 deletions(-) diff --git a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala index 4e8c7df..e051b0f 100644 --- a/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala +++ b/src/main/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandler.scala @@ -68,6 +68,7 @@ case class NoPlaintextForCiphertext(ciphertext: CiphertextType) @annotation.experimental class CloudflareDnsRecordHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel, Trace}](httpClient: Client[F], kms: KMS[F], + dnsRecordClient: StreamingCloudflareApiExecutor[F] => DnsRecordClient[Stream[F, *]], ) extends CloudFormationCustomResource[F, DnsRecordWithCredentials, JsonObject] { private implicit val logger: Logger[F] = LoggerFactory[F].getLogger @@ -75,7 +76,7 @@ class CloudflareDnsRecordHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyPara for { (email, key) <- decryptSensitiveProperties(input) executor = new StreamingCloudflareApiExecutor[F](httpClient, CloudflareAuthorization(email.value.toUTF8String, key.value.toUTF8String)) - } yield DnsRecordClient(executor) + } yield dnsRecordClient(executor) private def decrypt(ciphertext: CiphertextType): F[PlaintextType] = for { @@ -125,18 +126,18 @@ object CloudflareDnsRecordHandler extends IOLambda[CloudFormationCustomResourceR region <- Env[IO].get("AWS_REGION").liftEitherT(new RuntimeException("missing AWS_REGION environment variable")).map(AwsRegion(_)).rethrowT.toResource awsEnv <- AwsEnvironment.default(client, region) kms <- AwsClient(KMS, awsEnv) - yield buildHandler(xray, client, kms) + yield buildHandler(xray, client, kms)(DnsRecordClient(_)) def buildHandler[F[_] : {Concurrent, LoggerFactory, NonEmptyParallel}](entryPoint: EntryPoint[F], client: Client[F], - kms: KMS[F], - ) + kms: KMS[F]) + (dnsRecordClient: StreamingCloudflareApiExecutor[F] => DnsRecordClient[Stream[F, *]]) (using Local[F, Span[F]]): Invocation[F, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] => F[Option[Nothing]] = implicit inv => given KernelSource[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] = KernelSource.emptyKernelSource TracedHandler(entryPoint): - CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms)) + CloudFormationCustomResource(client, new CloudflareDnsRecordHandler(client, kms, dnsRecordClient)) } diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala index b7c8a21..3bb62fa 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala @@ -84,34 +84,15 @@ object CloudflareQueryParams { } -object CloudflareAuth { - type CloudflareAuthenticator[F[_], A] = CloudflareAuthorization => F[Option[A]] - - private def validate[F[_]](expected: CloudflareAuthorization): CloudflareAuthenticator[F, CloudflareAuthorization] = - Option(_).filter(_ == expected).pure[F] - - def apply[F[_]: Sync, A]: AuthMiddleware[F, A] = - challenged(challenge("Cloudflare", validate)) - - def challenge[F[_]: Applicative](expected: CloudflareAuthorization): Kleisli[F, Request[F], Either[Challenge, AuthedRequest[F, CloudflareAuthorization]]] = - Kleisli { req => - validatePassword(validate, req).map { - case Some(authInfo) => - Right(AuthedRequest(authInfo, req)) - case None => - Left(Challenge("Cloudflare", realm, authParams)) - } - } - - private def validatePassword[F[_] : Applicative](expected: CloudflareAuthorization, - req: Request[F]): F[Option[CloudflareAuthorization]] = - (req.headers.get[`X-Auth-Email`], req.headers.get[`X-Auth-Key`]) match { - case Some((Some(email), Some(key))) if email == expected.email && key == expected.key => - expected.some.pure[F] - case _ => - none.pure[F] - } - +class DnsRecordClientStub[F[+_]](const: F[Nothing]) extends DnsRecordClient[F] { + override def getById(zoneId: ZoneId, resourceId: ResourceId): F[IdentifiedDnsRecord] = const + override def createDnsRecord(record: UnidentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def updateDnsRecord(record: IdentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): F[IdentifiedDnsRecord] = const + override def deleteDnsRecord(physicalResourceId: String): F[PhysicalResourceId] = const + @targetName3("deleteDnsRecordNewtype") + override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): F[PhysicalResourceId] = const + override def getByUri(uri: String): F[IdentifiedDnsRecord] = const } @annotation.experimental @@ -188,7 +169,9 @@ class UpdateCloudflareSpec extends CatsEffectSuite { fakeCloudFormation <- ResponseCapturingHttpApp[IO] kmsExceptionMessage = "The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.raiseError(KeyUnavailableException(ErrorMessageType(kmsExceptionMessage).some))) - handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, fakeCloudFormation.client, mockKms) + handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, fakeCloudFormation.client, mockKms) { _ => + new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) + } request <- buildRequest( requestType = CloudFormationRequestType.UpdateRequest, physicalResourceId = cloudformation.PhysicalResourceId("different-physical-id"), @@ -204,7 +187,6 @@ class UpdateCloudflareSpec extends CatsEffectSuite { ).some, ) output <- handler(request) - _ <- entryPoint.ref.get.map(_.mkString_("\n")).flatMap(IO.println) response <- fakeCloudFormation.responses.take yield { assertEquals(output, None) @@ -230,47 +212,7 @@ class UpdateCloudflareSpec extends CatsEffectSuite { given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]) entryPoint <- InMemory.EntryPoint.create[IO] fakeCloudFormation <- ResponseCapturingHttpApp[IO] - cloudflare = HttpRoutes.of[IO] { - case req@GET -> Root / "client" / "v4" / "zones" :? domainNameQueryParamMatcher(name) +& statusQueryParamMatcher("active") => - Ok(ResponseDTO( - success = true, - result = ZoneDTO(id = idFromName(name).some, name = name), - errors = None, - messages = None, - ).asJson) - - case GET -> Root / "client" / "v4" / "zones" / idFromName(_) / "dns_records" :? domainNameQueryParamMatcher(_) +& typeQueryParamMatcher("CNAME") => - Ok(PagedResponseDTO( - result = List.empty[DnsRecordDTO], - success = true, - errors = None, - messages = None, - result_info = ResultInfoDTO( - page = 1, - per_page = 10, - count = 1, - total_pages = 1, - total_count = 1, - ).some - ).asJson) - - case req@POST -> Root / "client" / "v4" / "zones" / idFromName("dwolla.com") / "dns_records" => - given [A: Decoder]: EntityDecoder[IO, A] = jsonOf[IO, A] - - for - dto <- req.as[DnsRecordDTO] - _ <- Trace[IO].put("received" -> dto) - resp <- Ok(ResponseDTO( - result = dto.copy(id = idFromName(dto.name).some).some, - success = true, - errors = None, - messages = None, - ).asJson) - yield resp - } - - client = NatchezMiddleware.client(Client.fromHttpApp(entryPoint.liftRoutes((cloudflare) <+> fakeCloudFormation.routes).orNotFound)) - + client = NatchezMiddleware.client(fakeCloudFormation.client) mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.stub) { override def decrypt(ciphertextBlob: CiphertextType, encryptionContext: Option[Map[EncryptionContextKey, EncryptionContextValue]], @@ -282,7 +224,13 @@ class UpdateCloudflareSpec extends CatsEffectSuite { DecryptResponse(plaintext = PlaintextType(ciphertextBlob.value).some).pure[IO] } - handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, client, mockKms) + mockDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.empty + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(record.identifyAs(ZoneId(idFromName("dwolla.com")), ResourceId(idFromName(record.name)))) + } + + handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, client, mockKms)(_ => mockDnsRecordClient) request <- buildRequest( requestType = CloudFormationRequestType.CreateRequest, physicalResourceId = None, diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala index 496212b..171d40e 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala @@ -43,7 +43,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop - val handler = new CloudflareDnsRecordHandler[IO](dummyClient, failingKms) + val handler = new CloudflareDnsRecordHandler[IO](dummyClient, failingKms, DnsRecordClient(_)) val input = DnsRecordWithCredentials( dnsRecord = UnidentifiedDnsRecord( From fcb489f24592f1217e71d4211d5156c31a0c51bb Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 16:49:00 -0500 Subject: [PATCH 08/11] wip consolidate fakes --- .../CloudflareDnsRecordHandlerSpec.scala | 11 -------- .../record/DnsRecordClientStub.scala | 18 +++++++++++++ .../record/FakeDnsRecordClient.scala | 26 ------------------- .../record/UpdateCloudflareSuite.scala | 6 ++--- 4 files changed, 21 insertions(+), 40 deletions(-) create mode 100644 src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala delete mode 100644 src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala index 3bb62fa..095d0f1 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala @@ -84,17 +84,6 @@ object CloudflareQueryParams { } -class DnsRecordClientStub[F[+_]](const: F[Nothing]) extends DnsRecordClient[F] { - override def getById(zoneId: ZoneId, resourceId: ResourceId): F[IdentifiedDnsRecord] = const - override def createDnsRecord(record: UnidentifiedDnsRecord): F[IdentifiedDnsRecord] = const - override def updateDnsRecord(record: IdentifiedDnsRecord): F[IdentifiedDnsRecord] = const - override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): F[IdentifiedDnsRecord] = const - override def deleteDnsRecord(physicalResourceId: String): F[PhysicalResourceId] = const - @targetName3("deleteDnsRecordNewtype") - override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): F[PhysicalResourceId] = const - override def getByUri(uri: String): F[IdentifiedDnsRecord] = const -} - @annotation.experimental class UpdateCloudflareSpec extends CatsEffectSuite { diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala new file mode 100644 index 0000000..e7475e3 --- /dev/null +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala @@ -0,0 +1,18 @@ +package com.dwolla.lambda.cloudflare.record + +import fs2.* +import cats.effect.* +import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model.* +import org.typelevel.scalaccompat.annotation.targetName3 + +class DnsRecordClientStub[F[+_]](const: F[Nothing]) extends DnsRecordClient[F] { + override def getById(zoneId: ZoneId, resourceId: ResourceId): F[IdentifiedDnsRecord] = const + override def createDnsRecord(record: UnidentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def updateDnsRecord(record: IdentifiedDnsRecord): F[IdentifiedDnsRecord] = const + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): F[IdentifiedDnsRecord] = const + override def deleteDnsRecord(physicalResourceId: String): F[PhysicalResourceId] = const + @targetName3("deleteDnsRecordNewtype") + final override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): F[PhysicalResourceId] = deleteDnsRecord(physicalResourceId.value) + override def getByUri(uri: String): F[IdentifiedDnsRecord] = const +} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala deleted file mode 100644 index 5f3da09..0000000 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/FakeDnsRecordClient.scala +++ /dev/null @@ -1,26 +0,0 @@ -package com.dwolla.lambda.cloudflare.record - -import fs2.* -import cats.effect.* -import com.dwolla.cloudflare.* -import com.dwolla.cloudflare.domain.model.* -import org.typelevel.scalaccompat.annotation.targetName3 - -abstract class FakeDnsRecordClient extends DnsRecordClient[Stream[IO, *]] { - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) - - override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) - - override def getExistingDnsRecords(name: String, - content: Option[String], - recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) - - override def getById(zoneId: ZoneId, resourceId: ResourceId): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) - - override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = Stream.raiseError[IO](new NotImplementedError()) - - @targetName3("deleteDnsRecordNewtype") - final override def deleteDnsRecord(physicalResourceId: PhysicalResourceId): Stream[IO, PhysicalResourceId] = deleteDnsRecord(physicalResourceId.value) - - override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError[IO](new NotImplementedError()) -} diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala index 171d40e..3d5f3eb 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala @@ -84,7 +84,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { proxied = Option(true) ) - val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = if (record == inputRecord) Stream.emit(expectedRecord) else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) @@ -137,7 +137,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - val fakeCloudflareClient = new FakeDnsRecordClient { + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) @@ -172,7 +172,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { ) val existingRecord = inputRecord.identifyAs(physicalResourceId) - val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = Stream.emit(existingRecord).unNone From 4487cfb1f61accd799a482277250043dca19df77 Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 18:44:25 -0500 Subject: [PATCH 09/11] finish consolidating tests --- build.sbt | 3 +- .../CloudflareDnsRecordHandlerSpec.scala | 800 ------------------ .../record/DnsRecordClientStub.scala | 2 - .../record/UpdateCloudflareSuite.scala | 570 ++++++++++++- 4 files changed, 553 insertions(+), 822 deletions(-) delete mode 100644 src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala diff --git a/build.sbt b/build.sbt index 0f11a98..deb72ee 100644 --- a/build.sbt +++ b/build.sbt @@ -35,7 +35,8 @@ lazy val `cloudflare-public-hostname-lambda` = project "org.scalameta" %%% "munit" % "1.2.0" % Test, "org.scalameta" %%% "munit-scalacheck" % "1.2.0" % Test, "org.typelevel" %%% "scalacheck-effect-munit" % "2.1.0-RC1" % Test, - "org.tpolecat" %%% "natchez-testkit" % "0.3.8", + "org.tpolecat" %%% "natchez-testkit" % "0.3.8" % Test, + "org.typelevel" %%% "log4cats-testing" % "2.7.1" % Test, ) }, buildInfoKeys := Seq[BuildInfoKey]( diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala deleted file mode 100644 index 095d0f1..0000000 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/CloudflareDnsRecordHandlerSpec.scala +++ /dev/null @@ -1,800 +0,0 @@ -package com.dwolla.lambda.cloudflare.record - -import _root_.fs2.* -import _root_.io.circe.* -import _root_.io.circe.generic.auto.* -import _root_.io.circe.syntax.* -import _root_.io.circe.literal.* -import cats.* -import cats.data.* -import cats.syntax.all.* -import cats.effect.* -import cats.effect.std.* -import cats.mtl.* -import com.amazonaws.kms.* -import com.amazonaws.kms.KMSGen.DecryptError -import com.dwolla.cloudflare.* -import com.dwolla.cloudflare.domain.model -import com.dwolla.cloudflare.domain.model.* -import com.dwolla.cloudflare.domain.dto.* -import com.dwolla.cloudflare.domain.dto.dns.* -import com.dwolla.cloudflare.domain.model.Exceptions.RecordAlreadyExists -import com.dwolla.lambda.cloudflare.record.DnsRecordTypeChange -import feral.lambda.* -import feral.lambda.cloudformation -import feral.lambda.cloudformation.{PhysicalResourceId as _, *} -import org.typelevel.scalaccompat.annotation.targetName3 -import smithy4s.* -import smithy4s.kinds.* -import org.http4s.client.Client -import org.http4s.{BuildInfo as _, *} -import org.http4s.syntax.all.* -import org.http4s.dsl.* -import org.http4s.circe.* -import org.http4s.server.* -import org.http4s.server.middleware.authentication.challenged -import org.http4s.dsl.io.* -import munit.* -import natchez.* -import natchez.http4s.* -import natchez.mtl.* -import natchez.mtl.http4s.syntax.entrypoint.* -import org.typelevel.log4cats.LoggerFactory -import org.typelevel.log4cats.console.ConsoleLoggerFactory -import _root_.scodec.bits.* -import CloudflareQueryParams.* -import com.dwolla.tracing.LowPriorityTraceableValueInstances.* - -import scala.concurrent.duration.* - -class ResponseCapturingHttpApp[F[_] : Async](queue: Queue[F, Either[Json, CloudFormationCustomResourceResponse]]) { - private val dsl: Http4sDsl[F] = Http4sDsl[F] - import dsl.* - - private given [A: Decoder, B: Decoder]: Decoder[Either[A, B]] = - Decoder[B].map(_.asRight) or Decoder[A].map(_.asLeft) - - private given [A: Decoder]: EntityDecoder[F, A] = jsonOf - - def responses: QueueSource[F, Either[Json, CloudFormationCustomResourceResponse]] = queue - - def routes: HttpRoutes[F] = HttpRoutes.of[F] { - case req@PUT -> Root / "cloudformation-response" => - for - json <- req.as[Either[Json, CloudFormationCustomResourceResponse]] - _ <- queue.offer(json) - resp <- Ok() - yield resp - } - - def client: Client[F] = Client.fromHttpApp(routes.orNotFound) - - val uri: Uri = uri"/cloudformation-response" -} - -object ResponseCapturingHttpApp { - def apply[F[_] : Async]: F[ResponseCapturingHttpApp[F]] = - Queue.unbounded[F, Either[Json, CloudFormationCustomResourceResponse]].map(new ResponseCapturingHttpApp(_)) -} - -object CloudflareQueryParams { - object domainNameQueryParamMatcher extends QueryParamDecoderMatcher[String]("name") - object statusQueryParamMatcher extends QueryParamDecoderMatcher[String]("status") - object typeQueryParamMatcher extends QueryParamDecoderMatcher[String]("type") - -} - -@annotation.experimental -class UpdateCloudflareSpec extends CatsEffectSuite { - - given [F[_] : Sync]: LoggerFactory[F] = ConsoleLoggerFactory.create - - private val tagPhysicalResourceId: String => model.PhysicalResourceId = PhysicalResourceId(_) - private val tagZoneId: String => model.ZoneId = ZoneId(_) - private val tagResourceId: String => model.ResourceId = ResourceId(_) - - given Show[InMemory.Lineage] with - def show(lineage: InMemory.Lineage): String = lineage match - case InMemory.Lineage.Root(name) => name - case InMemory.Lineage.Child(parent, name) => s"${parent.show}/${name.show}" - - given Show[Kernel] with - def show(kernel: Kernel): String = kernel.toHeaders.map { case (k, v) => s"$k: $v" }.mkString(", ") - - given Show[Span.Options] with - def show(options: Span.Options): String = options.toString - - given Show[List[(String, TraceValue)]] with - def show(fields: List[(String, TraceValue)]): String = fields.map { - case (k, TraceValue.StringValue(v)) => s"$k: $v" - case (k, TraceValue.BooleanValue(v)) => s"$k: $v" - case (k, TraceValue.NumberValue(v)) => s"$k: $v" - } - .mkString(", ") - - given Show[InMemory.NatchezCommand] with - def show(command: InMemory.NatchezCommand): String = command match - case InMemory.NatchezCommand.AskKernel(kernel) => s"AskKernel(${kernel.show})" - case InMemory.NatchezCommand.AskSpanId => "AskSpanId" - case InMemory.NatchezCommand.AskTraceId => "AskTraceId" - case InMemory.NatchezCommand.AskTraceUri => "AskTraceUri" - case InMemory.NatchezCommand.Put(fields) => s"Put(${fields.show})" - case InMemory.NatchezCommand.CreateSpan(name, kernel, options) => s"CreateSpan($name, ${kernel.show}, ${options.show})" - case InMemory.NatchezCommand.ReleaseSpan(name) => s"ReleaseSpan($name)" - case InMemory.NatchezCommand.AttachError(err, fields) => s"AttachError(${Option(err.getMessage).getOrElse(err.toString)} (${err.getStackTrace.headOption.map(_.toString).getOrElse("no stack trace")}), ${fields.show})" - case InMemory.NatchezCommand.LogEvent(event) => s"LogEvent($event)" - case InMemory.NatchezCommand.LogFields(fields) => s"LogFields(${fields.show})" - case InMemory.NatchezCommand.CreateRootSpan(name, kernel, options) => s"CreateRootSpan($name, ${kernel.show}, ${options.show})" - case InMemory.NatchezCommand.ReleaseRootSpan(name) => s"ReleaseRootSpan($name)" - - /** - * This object provides functionality to generate an identifier from a given name - * and to extract the original name from a given identifier. - * - * The `apply` method is responsible for converting a string input (`name`) - * into its hexadecimal representation. This serves as a form of identifier or key. - * - * The `unapply` method performs the reverse operation, taking a hexadecimal identifier - * and decoding it back into its original string, if possible. - */ - private object idFromName { - def apply(name: String): String = - BitVector - .encodeUtf8(name) - .map(_.toHex) - .toOption - .get - - def unapply(id: String): Option[String] = - BitVector - .fromHex(id) - .flatMap(_.decodeUtf8.toOption) - } - - test("CloudflareDnsRecordHandler should propagate exceptions thrown by the KMS decrypter") { - for - given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]) - entryPoint <- InMemory.EntryPoint.create[IO] - fakeCloudFormation <- ResponseCapturingHttpApp[IO] - kmsExceptionMessage = "The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" - mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.raiseError(KeyUnavailableException(ErrorMessageType(kmsExceptionMessage).some))) - handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, fakeCloudFormation.client, mockKms) { _ => - new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) - } - request <- buildRequest( - requestType = CloudFormationRequestType.UpdateRequest, - physicalResourceId = cloudformation.PhysicalResourceId("different-physical-id"), - responseUri = fakeCloudFormation.uri, - resourceProperties = Map( - "Name" -> Json.fromString("example.dwolla.com"), - "Content" -> Json.fromString("new-example.dwollalabs.com"), - "Type" -> Json.fromString("CNAME"), - "TTL" -> Json.fromString("42"), - "Proxied" -> Json.fromString("true"), - "CloudflareEmail" -> Json.fromString(utf8Bytes"cloudflare-account-email@dwollalabs.com".toBase64), - "CloudflareKey" -> Json.fromString(utf8Bytes"fake-key".toBase64) - ).some, - ) - output <- handler(request) - response <- fakeCloudFormation.responses.take - yield { - assertEquals(output, None) - assertEquals(response, CloudFormationCustomResourceResponse( - Status = RequestResponseStatus.Failed, - Reason = kmsExceptionMessage.some, - PhysicalResourceId = cloudformation.PhysicalResourceId("different-physical-id"), - StackId = StackId(""), - RequestId = RequestId(""), - LogicalResourceId = LogicalResourceId(""), - Data = - json"""{ - "StackTrace": [ - "com.amazonaws.kms.KeyUnavailableException: The ciphertext refers to a customer master key that does not exist, does not exist in this region, or you are not allowed to access" - ] - }""", - ).asRight) - } - } - - test("UpdateCloudflare create should create specified CNAME record") { - for - given Local[IO, Span[IO]] <- IO.local(Span.noop[IO]) - entryPoint <- InMemory.EntryPoint.create[IO] - fakeCloudFormation <- ResponseCapturingHttpApp[IO] - client = NatchezMiddleware.client(fakeCloudFormation.client) - mockKms = new KMSGen.Constant[Kind1[IO]#toKind5](IO.stub) { - override def decrypt(ciphertextBlob: CiphertextType, - encryptionContext: Option[Map[EncryptionContextKey, EncryptionContextValue]], - grantTokens: Option[List[GrantTokenType]], - keyId: Option[KeyIdType], - encryptionAlgorithm: Option[EncryptionAlgorithmSpec], - recipient: Option[RecipientInfo], - dryRun: Option[NullableBooleanType]): IO[DecryptResponse] = - DecryptResponse(plaintext = PlaintextType(ciphertextBlob.value).some).pure[IO] - } - - mockDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { - override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.empty - override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = - Stream.emit(record.identifyAs(ZoneId(idFromName("dwolla.com")), ResourceId(idFromName(record.name)))) - } - - handler = CloudflareDnsRecordHandler.buildHandler(entryPoint, client, mockKms)(_ => mockDnsRecordClient) - request <- buildRequest( - requestType = CloudFormationRequestType.CreateRequest, - physicalResourceId = None, - responseUri = fakeCloudFormation.uri, - resourceProperties = Map( - "Name" -> Json.fromString("example.dwolla.com"), - "Content" -> Json.fromString("example.dwollalabs.com"), - "Type" -> Json.fromString("CNAME"), - "TTL" -> Json.fromString("42"), - "Proxied" -> Json.fromString("true"), - "CloudflareEmail" -> Json.fromString(utf8Bytes"cloudflare-account-email@dwollalabs.com".toBase64), - "CloudflareKey" -> Json.fromString(utf8Bytes"fake-key".toBase64) - ).some, - ) - output <- handler(request) - captured <- fakeCloudFormation.responses.take - yield - val expectedPhysicalResourceId = s"https://api.cloudflare.com/client/v4/zones/${idFromName("dwolla.com")}/dns_records/${idFromName("example.dwolla.com")}" - val expected = - json"""{ - "physicalResourceId" : $expectedPhysicalResourceId, - "zoneId" : ${idFromName("dwolla.com")}, - "resourceId" : ${idFromName("example.dwolla.com")}, - "name" : "example.dwolla.com", - "content" : "example.dwollalabs.com", - "recordType" : "CNAME", - "ttl" : 42, - "proxied" : true - }""" - assertEquals(output, None) - assertEquals(captured, CloudFormationCustomResourceResponse( - Status = RequestResponseStatus.Success, - Reason = None, - PhysicalResourceId = cloudformation.PhysicalResourceId(expectedPhysicalResourceId), - StackId = StackId(""), - RequestId = RequestId(""), - LogicalResourceId = LogicalResourceId(""), - Data = - json"""{ - "dnsRecord": $expected, - "created": $expected - }""", - ).asRight) - } - -// test("UpdateCloudflare create should log failure and close the clients if creation fails") { -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord) Stream.raiseError(NoStackTraceException) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) -// -// output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await -// } -// -// test("UpdateCloudflare create should propagate exception if fetching existing records fails") { -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// Stream.raiseError(NoStackTraceException) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) -// -// output.attempt.compile.toList.map(_.head).unsafeToFuture() must beLeft[Throwable](NoStackTraceException).await -// } -// -// test("UpdateCloudflare create should create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation") { -// val providedPhysicalId = Option("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// val expectedRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord) Stream.emit(expectedRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, providedPhysicalId) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== expectedRecord.physicalResourceId -// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) -// }.await -// } -// -// test("UpdateCloudflare create should create a DNS record that isn't an CNAME even if record(s) with the same name already exist") { -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true), -// priority = Option(10), -// ) -// val expectedRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true), -// priority = Option(10), -// ) -// -// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord) Stream.emit(expectedRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) -// handlerResponse.data must havePair("created" -> expectedRecord.asJson) -// handlerResponse.data must havePair("updated" -> None.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) -// }.await -// } -// -// test("UpdateCloudflare create should pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists") { -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true), -// priority = Option(10), -// ) -// val expectedRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true), -// priority = Option(10), -// ) -// val existingRecord = expectedRecord.copy( -// physicalResourceId = tagPhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), -// resourceId = tagResourceId("different-record"), -// content = "different-content", -// priority = Option(0), -// ) -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == existingRecord.name) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, None) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== existingRecord.physicalResourceId -// handlerResponse.data must havePair("dnsRecord" -> existingRecord.asJson) -// handlerResponse.data must havePair("created" -> existingRecord.asJson) -// handlerResponse.data must havePair("updated" -> None.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> None.asJson) -// }.await -// } -// -// test("CloudflareDnsRecordHandler update should update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true), -// priority = Option(10), -// ) -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = None, -// priority = Option(10), -// ) -// -// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = -// if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== expectedRecord.physicalResourceId -// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) -// }.await -// -// // TODO deal with logging -//// there were noCallsTo(mockLogger) -// } -// -// test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true), -// ) -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == existingRecord.name) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("CrEaTe", inputRecord, Option(physicalResourceId)) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== expectedRecord.physicalResourceId -// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) -// }.await -// -//// there was one(mockLogger).warn(startsWith("""Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com"""")) -// } -// -// test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") -// val inputRecord = existingRecord.unidentify -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = -// if (record == inputRecord.identifyAs(physicalResourceId)) Stream.emit(expectedRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == existingRecord.name) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== expectedRecord.physicalResourceId -// handlerResponse.data must havePair("dnsRecord" -> expectedRecord.asJson) -// handlerResponse.data must havePair("oldDnsRecord" -> existingRecord.asJson) -// }.await -// -// // TODO deal with logging -//// there was one(mockLogger).warn(startsWith( -//// """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com".""")) -// } -// -// test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is CNAME") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "A", -// ttl = Option(42), -// proxied = Option(true) -// ) -// val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") -// -// val fakeCloudflareClient = new FakeDnsRecordClient { -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) -// -// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { -// case DnsRecordTypeChange(existingRecordType, newRecordType) => -// existingRecordType must_== "A" -// newRecordType must_== "CNAME" -// } -// } -// -// test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is not CNAME") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "MX", -// ttl = Option(42), -// proxied = Option(true) -// ) -// val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") -// -// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = -// Stream.emit(existingRecord) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) -// -// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable].like { -// case DnsRecordTypeChange(existingRecordType, newRecordType) => -// existingRecordType must_== "MX" -// newRecordType must_== "TXT" -// } -// } -// -// test("CloudflareDnsRecordHandler update should propagate the failure exception if update fails") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val existingRecord = IdentifiedDnsRecord( -// physicalResourceId = tagPhysicalResourceId(physicalResourceId), -// zoneId = tagZoneId("fake-zone-id"), -// resourceId = tagResourceId("fake-resource-id"), -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") -// -// val fakeCloudflareClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(NoStackTraceException) -// -// override def getExistingDnsRecords(name: String, -// content: Option[String], -// recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = -// if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) -// else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) -// } -// -// val output = UpdateCloudflare(fakeCloudflareClient)("update", inputRecord, Option(physicalResourceId)) -// -// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) -// } -// -// test("CloudflareDnsRecordHandler delete should delete a DNS record if requested") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// val existingRecord = inputRecord.identifyAs(physicalResourceId) -// -// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = -// Stream.emit(existingRecord) -// -// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = -// Stream.emit(physicalResourceId).map(tagPhysicalResourceId) -// } -// -// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== physicalResourceId -// handlerResponse.data must havePair("deletedRecordId" -> physicalResourceId.asJson) -// }.await -// } -// -// test("CloudflareDnsRecordHandler delete should delete is successful even if the physical ID passed by CloudFormation doesn't exist") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = -// Stream.empty -// -// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = -// Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) -// } -// -// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) -// -// output.compile.toList.unsafeToFuture() must beLike[List[HandlerResponse]] { -// case List(handlerResponse) => -// handlerResponse.physicalId must_== physicalResourceId -// handlerResponse.data must not(havePair("deletedRecordId" -> physicalResourceId)) -// }.await -// -// // TODO deal with logging -//// there was one(mockLogger).error("The record could not be deleted because it did not exist; nonetheless, responding with Success!", -//// DnsRecordIdDoesNotExistException("fake-url")) -// } -// -// test("CloudflareDnsRecordHandler delete should log failure and close the clients if delete fails") { -// val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" -// val inputRecord = UnidentifiedDnsRecord( -// name = "example.dwolla.com", -// content = "example.dwollalabs.com", -// recordType = "CNAME", -// ttl = Option(42), -// proxied = Option(true) -// ) -// -// val fakeDnsRecordClient: FakeDnsRecordClient = new FakeDnsRecordClient { -// override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = -// Stream.emit(inputRecord.identifyAs(physicalResourceId)) -// -// override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = -// Stream.raiseError(NoStackTraceException) -// } -// -// val output = UpdateCloudflare(fakeDnsRecordClient)("delete", inputRecord, Option(physicalResourceId)) -// -// output.attempt.compile.toList.map(_.head).unsafeRunSync() must beLeft[Throwable](NoStackTraceException) -// } -// -// test("DnsRecordTypeChange should mention the existing and new record types") { -// DnsRecordTypeChange("existing", "new") must beLikeA[RuntimeException] { -// case ex => ex.getMessage must_== """Refusing to change DNS record from "existing" to "new".""" -// } -// } - - private def buildRequest(requestType: CloudFormationRequestType, - physicalResourceId: Option[cloudformation.PhysicalResourceId], - resourceProperties: Option[Map[String, Json]], - responseUri: Uri, - ): IO[Invocation[IO, CloudFormationCustomResourceRequest[DnsRecordWithCredentials]]] = { - json"""{ - "RequestType": $requestType, - "ResponseURL": $responseUri, - "StackId": "", - "RequestId": "", - "ResourceType": "", - "LogicalResourceId": "", - "PhysicalResourceId": $physicalResourceId, - "ResourceProperties": $resourceProperties, - "OldResourceProperties": null - }""" - .as[CloudFormationCustomResourceRequest[DnsRecordWithCredentials]] - .map(Invocation.pure(_, Context( - functionName = "CloudflareDnsRecordHandler", - functionVersion = BuildInfo.version, - invokedFunctionArn = "", - memoryLimitInMB = 512, // TODO get from buildinfo - awsRequestId = "", - logGroupName = "", - logStreamName = "", - identity = None, - clientContext = None, - remainingTime = 60.seconds.pure[IO] - ))) - .liftTo[IO] - } - -} - -case class CustomNoStackTraceException(msg: String, ex: Throwable = null) extends RuntimeException(msg, ex, true, false) diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala index e7475e3..ccebfbd 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/DnsRecordClientStub.scala @@ -1,7 +1,5 @@ package com.dwolla.lambda.cloudflare.record -import fs2.* -import cats.effect.* import com.dwolla.cloudflare.* import com.dwolla.cloudflare.domain.model.* import org.typelevel.scalaccompat.annotation.targetName3 diff --git a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala index 3d5f3eb..443b72c 100644 --- a/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala +++ b/src/test/scala/com/dwolla/lambda/cloudflare/record/UpdateCloudflareSuite.scala @@ -1,28 +1,41 @@ package com.dwolla.lambda.cloudflare.record +import cats.* import cats.effect.* import cats.syntax.all.* +import cats.tagless.aop.* +import cats.tagless.syntax.all.* import com.amazonaws.kms.* import com.dwolla.cloudflare.* +import com.dwolla.cloudflare.domain.model import com.dwolla.cloudflare.domain.model.* +import com.dwolla.cloudflare.domain.model.Exceptions.* +import feral.lambda.cloudformation import fs2.Stream import io.circe.* import io.circe.syntax.* import munit.{CatsEffectSuite, Compare} import natchez.Trace import org.http4s.client.Client -import org.typelevel.log4cats.console.ConsoleLoggerFactory +import org.typelevel.log4cats.testing.* import org.typelevel.log4cats.{Logger, LoggerFactory} -import org.typelevel.scalaccompat.annotation.targetName3 import smithy4s.{Bijection, Blob} +import scala.util.control.NoStackTrace + @annotation.experimental class UpdateCloudflareSuite extends CatsEffectSuite { given [A, B](using Bijection[B, A], Compare[A, A]): Compare[A, B] = (obtained: A, expected: B) => summon[Compare[A, A]].isEqual(obtained, summon[Bijection[B, A]].to(expected)) + private def stub[Alg[_[_]] : Instrument, F[_] : ApplicativeThrow](alg: Alg[F]): Alg[F] = + alg.instrument.mapK(new EnhancedStubWithInstrumentation) + test("CloudflareDnsRecordHandler propagates exceptions thrown by the KMS client (smithy4s)") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Trace[IO] = natchez.Trace.Implicits.noop + val kmsErrorMessage = "The ciphertext refers to a KMS key you cannot access" val failingKms: KMS[IO] = new KMS[IO] { @@ -40,9 +53,6 @@ class UpdateCloudflareSuite extends CatsEffectSuite { // minimal Client that should never be used in this test (decrypt fails first) val dummyClient: Client[IO] = Client[IO](_ => Resource.eval(IO.raiseError(new RuntimeException("HTTP should not be called")))) - implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] - implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop - val handler = new CloudflareDnsRecordHandler[IO](dummyClient, failingKms, DnsRecordClient(_)) val input = DnsRecordWithCredentials( @@ -58,14 +68,15 @@ class UpdateCloudflareSuite extends CatsEffectSuite { ) interceptMessageIO[InvalidCiphertextException](kmsErrorMessage) { - handler.updateResource(input, feral.lambda.cloudformation.PhysicalResourceId.unsafeApply("different-physical-id")) + handler.updateResource(input, cloudformation.PhysicalResourceId.unsafeApply("different-physical-id")) } } test("create specified CNAME record") { - implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] - implicit val logger: Logger[IO] = loggerFactory.getLogger - implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + val inputRecord = UnidentifiedDnsRecord( name = "example.dwolla.com", content = "example.dwollalabs.com", @@ -84,7 +95,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { proxied = Option(true) ) - val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = if (record == inputRecord) Stream.emit(expectedRecord) else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) @@ -110,9 +121,10 @@ class UpdateCloudflareSuite extends CatsEffectSuite { } test("update a non-CNAME DNS record if it already exists, with physical ID from CloudFormation") { - implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] - implicit val logger: Logger[IO] = loggerFactory.getLogger - implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") val inputRecord = UnidentifiedDnsRecord( @@ -137,7 +149,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") - val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { + val fakeCloudflareClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) else Stream.raiseError[IO](new RuntimeException(s"unexpected argument: $record")) @@ -159,9 +171,10 @@ class UpdateCloudflareSuite extends CatsEffectSuite { } test("delete a DNS record if requested") { - implicit val loggerFactory: LoggerFactory[IO] = ConsoleLoggerFactory.create[IO] - implicit val logger: Logger[IO] = loggerFactory.getLogger - implicit val trace: Trace[IO] = natchez.Trace.Implicits.noop + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + val physicalResourceId = "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" val inputRecord = UnidentifiedDnsRecord( name = "example.dwolla.com", @@ -172,7 +185,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { ) val existingRecord = inputRecord.identifyAs(physicalResourceId) - val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](new NotImplementedError)) { + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = Stream.emit(existingRecord).unNone @@ -180,7 +193,7 @@ class UpdateCloudflareSuite extends CatsEffectSuite { Stream.emit(PhysicalResourceId(physicalResourceId)) } - val output = UpdateCloudflare(fakeDnsRecordClient).handleDelete(feral.lambda.cloudformation.PhysicalResourceId.unsafeApply(physicalResourceId)) + val output = UpdateCloudflare(fakeDnsRecordClient).handleDelete(cloudformation.PhysicalResourceId.unsafeApply(physicalResourceId)) output.flatMap { handlerResponse => IO { @@ -189,4 +202,523 @@ class UpdateCloudflareSuite extends CatsEffectSuite { } } } + + test("UpdateCloudflare create should propagate exceptions thrown by the Cloudflare client") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val expectedInputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, content: Option[String], recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = Stream.empty + + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == expectedInputRecord) Stream.raiseError(NoStackTraceException) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + } + + interceptMessageIO[NoStackTraceException.type](NoStackTraceException.getMessage) { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(expectedInputRecord, None) + } + } + + test("UpdateCloudflare create should propagate exception if fetching existing records fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + Stream.raiseError(NoStackTraceException) + } + + interceptMessageIO[NoStackTraceException.type](NoStackTraceException.getMessage) { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, None) + } + } + + test("UpdateCloudflare create should create a CNAME record if it doesn't exist, despite having a physical ID provided by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val providedPhysicalId = cloudformation.PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == "example.dwolla.com" && recordType.contains("CNAME")) Stream.empty + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, providedPhysicalId) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare create should create a DNS record that isn't an CNAME even if record(s) with the same name already exist") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, None) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(resp.data.exists(_.apply("created").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("updated").isEmpty)) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare create should pretend to have created a DNS record that isn't an CNAME if Cloudflare complains that the record already exists") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val expectedRecord = IdentifiedDnsRecord( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id"), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = expectedRecord.copy( + physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/different-record"), + resourceId = ResourceId("different-record"), + content = "different-content", + priority = Option(0), + ) + + val fakeDnsRecordClient = new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def createDnsRecord(record: UnidentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (record == inputRecord) Stream.raiseError(RecordAlreadyExists) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + } + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, None) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, existingRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(existingRecord.asJson))) + assert(resp.data.exists(_.apply("created").contains(existingRecord.asJson))) + assert(!resp.data.exists(_.apply("updated").isEmpty)) + assert(!resp.data.exists(_.apply("oldDnsRecord").isEmpty)) + } + } + } + + test("UpdateCloudflare update should update a non-CNAME DNS record if it already exists, if its physical ID is passed in by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true), + priority = Option(10), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = None, + priority = Option(10), + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: expected ${inputRecord.identifyAs(physicalResourceId)} but received $record")) + + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + if (physicalResourceId == existingRecord.physicalResourceId) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($physicalResourceId)")) + }) + + UpdateCloudflare(fakeDnsRecordClient) + .handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + .flatMap { resp => + IO { + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assert(resp.data.exists(_.apply("dnsRecord").contains(expectedRecord.asJson))) + assert(!resp.data.exists(_.apply("created").isEmpty)) + assert(resp.data.exists(_.apply("updated").contains(expectedRecord.asJson))) + assert(resp.data.exists(_.apply("oldDnsRecord").contains(existingRecord.asJson))) + } + } + } + + test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if no physical ID is passed in by CloudFormation") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true), + ) + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, None) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assertEquals(resp.data, JsonObject( + "dnsRecord" -> expectedRecord.asJson, + "created" -> None.asJson, + "updated" -> expectedRecord.asJson, + "oldDnsRecord" -> existingRecord.asJson, + ).some) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """Discovered DNS record ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com", with existing content "example.dwollalabs.com". This record will be updated instead of creating a new record.""", None))) + } + + test("CloudflareDnsRecordHandler update should update a CNAME DNS record if it already exists, even if the physical ID passed in by CloudFormation doesn't match the existing ID (returning the new ID)") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + val expectedRecord = existingRecord.copy(content = "new-example.dwollalabs.com") + val inputRecord = existingRecord.unidentify + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = + if (inputRecord.identifyAs(physicalResourceId).contains(record)) Stream.emit(expectedRecord) + else Stream.raiseError(new RuntimeException(s"unexpected argument: $record")) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, cloudformation.PhysicalResourceId("different-physical-id")) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, expectedRecord.physicalResourceId) + assertEquals(resp.data, JsonObject( + "dnsRecord" -> expectedRecord.asJson, + "created" -> None.asJson, + "updated" -> expectedRecord.asJson, + "oldDnsRecord" -> existingRecord.asJson, + ).some) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """The passed physical ID "different-physical-id" does not match the discovered physical ID "https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id" for hostname "example.dwolla.com". This may indicate a change to this stack's DNS entries that was not managed by CloudFormation. Updating the discovered record instead of the record passed by CloudFormation.""", None))) + } + + test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is CNAME") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = (physicalResourceId), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "A", + ttl = Option(42), + proxied = Option(true) + ) + val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwollalabs.com", recordType = "CNAME") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name && recordType.contains("CNAME")) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + + interceptIO[DnsRecordTypeChange] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + }.map(assertEquals(_, DnsRecordTypeChange("A", "CNAME"))) + } + + test("CloudflareDnsRecordHandler update should refuse to change the record type if the input type is not CNAME") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = (physicalResourceId), + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "MX", + ttl = Option(42), + proxied = Option(true) + ) + val inputRecord = existingRecord.unidentify.copy(content = "new text", recordType = "TXT") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(existingRecord) + }) + + interceptIO[DnsRecordTypeChange] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + }.map(assertEquals(_, DnsRecordTypeChange("MX", "TXT"))) + } + + test("CloudflareDnsRecordHandler update should propagate the failure exception if update fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val existingRecord = IdentifiedDnsRecord( + physicalResourceId = physicalResourceId, + zoneId = ZoneId("fake-zone-id"), + resourceId = ResourceId("fake-resource-id"), + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val inputRecord = existingRecord.unidentify.copy(content = "new-example.dwolla.com") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def updateDnsRecord(record: IdentifiedDnsRecord): Stream[IO, IdentifiedDnsRecord] = Stream.raiseError(NoStackTraceException) + + override def getExistingDnsRecords(name: String, + content: Option[String], + recordType: Option[String]): Stream[IO, IdentifiedDnsRecord] = + if (name == existingRecord.name && recordType.contains(existingRecord.recordType)) Stream.emit(existingRecord) + else Stream.raiseError(new RuntimeException(s"unexpected arguments: ($name, $content, $recordType)")) + }) + + interceptIO[NoStackTraceException.type] { + UpdateCloudflare(fakeDnsRecordClient).handleCreateOrUpdate(inputRecord, physicalResourceIdBijection.to(physicalResourceId).some) + } + } + + test("CloudflareDnsRecordHandler delete should delete is successful even if the physical ID passed by CloudFormation doesn't exist") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.empty + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.raiseError(DnsRecordIdDoesNotExistException("fake-url")) + }) + + for + resp <- UpdateCloudflare(fakeDnsRecordClient).handleDelete(physicalResourceIdBijection.to(physicalResourceId)) + log <- summon[TestingLoggerFactory[IO]].logged + yield + assertEquals(resp.physicalId, physicalResourceId) + assertEquals(resp.data, None) + assertEquals(log, Vector(TestingLoggerFactory.Warn("com.dwolla.lambda.cloudflare.record.UpdateCloudflareSuite", """The record could not be deleted because it did not exist; nonetheless, responding with Success!""", None))) + } + + test("CloudflareDnsRecordHandler delete should propagate exceptions thrown by the Cloudflare client when a delete fails") { + given TestingLoggerFactory[IO] = TestingLoggerFactory.atomic[IO]() + given Logger[IO] = LoggerFactory[IO].getLogger + given Trace[IO] = natchez.Trace.Implicits.noop + + val physicalResourceId: model.PhysicalResourceId = PhysicalResourceId("https://api.cloudflare.com/client/v4/zones/fake-zone-id/dns_records/fake-resource-id") + val inputRecord = UnidentifiedDnsRecord( + name = "example.dwolla.com", + content = "example.dwollalabs.com", + recordType = "CNAME", + ttl = Option(42), + proxied = Option(true) + ) + + val fakeDnsRecordClient = stub(new DnsRecordClientStub(Stream.raiseError[IO](StubException)) { + override def getByUri(uri: String): Stream[IO, IdentifiedDnsRecord] = + Stream.emit(inputRecord.identifyAs(physicalResourceId)).unNone + + override def deleteDnsRecord(physicalResourceId: String): Stream[IO, PhysicalResourceId] = + Stream.raiseError(NoStackTraceException) + }) + + interceptIO[NoStackTraceException.type] { + UpdateCloudflare(fakeDnsRecordClient).handleDelete(physicalResourceIdBijection.to(physicalResourceId)) + } + } + + test("DnsRecordTypeChange should mention the existing and new record types") { + interceptMessage("""Refusing to change DNS record from "existing" to "new".""") { + throw DnsRecordTypeChange("existing", "new") + } + } + +} + +case object NoStackTraceException extends NoStackTrace { + override def getMessage: String = "NoStackTraceException" + override def toString: String = getMessage +} + +case object StubException extends NoStackTrace +class EnhancedStubWithInstrumentation[F[_] : ApplicativeThrow] extends (Instrumentation[F, *] ~> F) { + override def apply[A](fa: Instrumentation[F, A]): F[A] = + fa.value.recoverWith { + case StubException => new NotImplementedError(s"An implementation is missing for ${fa.algebraName}.${fa.methodName}").raiseError[F, A] + } } From 103ee59ad7adfa2ea8d219e282dcca582735f706 Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 18:48:20 -0500 Subject: [PATCH 10/11] add mergify --- .mergify.yml | 17 +++++++++++++++++ build.sbt | 13 +++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .mergify.yml diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 0000000..8393230 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,17 @@ +# This file was automatically generated by sbt-typelevel-mergify using the +# mergifyGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the mergify configuration +# to meet your needs, then regenerate this file. + +pull_request_rules: +- name: merge scala-steward's PRs + conditions: + - author=dwolla-oss-scala-steward[bot] + - or: + - body~=labels:.*early-semver-patch + - body~=labels:.*early-semver-minor + - status-success=Test (ubuntu-22.04, 3, temurin@11) + actions: + merge: {} diff --git a/build.sbt b/build.sbt index deb72ee..9841d6f 100644 --- a/build.sbt +++ b/build.sbt @@ -2,8 +2,21 @@ evictionErrorLevel := Level.Warn ThisBuild / organization := "Dwolla" ThisBuild / homepage := Option(url("https://github.com/Dwolla/cloudflare-public-hostname-lambda")) +ThisBuild / licenses += ("MIT", url("http://opensource.org/licenses/MIT")) ThisBuild / scalaVersion := "3.7.3" +ThisBuild / developers := List( + Developer( + "bpholt", + "Brian Holt", + "bholt+github@dwolla.com", + url("https://dwolla.com") + ), +) ThisBuild / resolvers += Resolver.sonatypeCentralSnapshots +ThisBuild / mergifyStewardConfig ~= { _.map { + _.withAuthor("dwolla-oss-scala-steward[bot]") + .withMergeMinors(true) +}} lazy val `cloudflare-public-hostname-lambda` = project .in(file(".")) From 541185e26c470e40b0af496062a5b841e2bdeed7 Mon Sep 17 00:00:00 2001 From: Brian Holt Date: Wed, 22 Oct 2025 18:57:33 -0500 Subject: [PATCH 11/11] more build tweaks --- .dwollaci.yml | 3 +- .github/workflows/ci.yml | 63 +++++++++++++++++++++++++++++++++++++ .github/workflows/clean.yml | 59 ++++++++++++++++++++++++++++++++++ .sdkmanrc | 1 + build.sbt | 4 +++ project/plugins.sbt | 5 +-- 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/clean.yml create mode 100644 .sdkmanrc diff --git a/.dwollaci.yml b/.dwollaci.yml index a6bb143..079994b 100644 --- a/.dwollaci.yml +++ b/.dwollaci.yml @@ -12,8 +12,7 @@ stages: set -o xtrace sbt test sbt doc - sbt autoscaling-ecs-draining-lambda/Universal/packageBin - sbt registrator-health-check-lambda/Universal/packageBin + sbt npmPackage filesToStash: - '**' deployProd: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b01e452 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Continuous Integration + +on: + pull_request: + branches: ['**'] + push: + branches: ['**'] + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + +concurrency: + group: ${{ github.workflow }} @ ${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Test + strategy: + matrix: + os: [ubuntu-22.04] + scala: [3] + java: [temurin@11] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Checkout current branch (full) + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup sbt + uses: sbt/setup-sbt@v1 + + - name: Setup Java (temurin@11) + id: setup-java-temurin-11 + if: matrix.java == 'temurin@11' + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 11 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@11' && steps.setup-java-temurin-11.outputs.cache-hit == 'false' + run: sbt +update + + - name: Check that workflows are up to date + run: sbt githubWorkflowCheck + + - name: Build project + run: sbt '++ ${{ matrix.scala }}' test + + - name: Package + run: sbt '++ ${{ matrix.scala }}' npmPackage diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml new file mode 100644 index 0000000..547aaa4 --- /dev/null +++ b/.github/workflows/clean.yml @@ -0,0 +1,59 @@ +# This file was automatically generated by sbt-github-actions using the +# githubWorkflowGenerate task. You should add and commit this file to +# your git repository. It goes without saying that you shouldn't edit +# this file by hand! Instead, if you wish to make changes, you should +# change your sbt build configuration to revise the workflow description +# to meet your needs, then regenerate this file. + +name: Clean + +on: push + +jobs: + delete-artifacts: + name: Delete Artifacts + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Delete artifacts + run: | + # Customize those three lines with your repository and credentials: + REPO=${GITHUB_API_URL}/repos/${{ github.repository }} + + # A shortcut to call GitHub API. + ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } + + # A temporary file which receives HTTP response headers. + TMPFILE=/tmp/tmp.$$ + + # An associative array, key: artifact name, value: number of artifacts of that name. + declare -A ARTCOUNT + + # Process all artifacts on this repository, loop on returned "pages". + URL=$REPO/actions/artifacts + while [[ -n "$URL" ]]; do + + # Get current page, get response headers in a temporary file. + JSON=$(ghapi --dump-header $TMPFILE "$URL") + + # Get URL of next page. Will be empty if we are at the last page. + URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') + rm -f $TMPFILE + + # Number of artifacts on this page: + COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) + + # Loop on all artifacts on this page. + for ((i=0; $i < $COUNT; i++)); do + + # Get name of artifact and count instances of this name. + name=$(jq <<<$JSON -r ".artifacts[$i].name?") + ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) + + id=$(jq <<<$JSON -r ".artifacts[$i].id?") + size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) + printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size + ghapi -X DELETE $REPO/actions/artifacts/$id + done + done diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 0000000..598592a --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1 @@ +java=11.0.28-tem diff --git a/build.sbt b/build.sbt index 9841d6f..191d384 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,5 @@ +import org.typelevel.sbt.gha.WorkflowStep + evictionErrorLevel := Level.Warn ThisBuild / organization := "Dwolla" @@ -17,6 +19,8 @@ ThisBuild / mergifyStewardConfig ~= { _.map { _.withAuthor("dwolla-oss-scala-steward[bot]") .withMergeMinors(true) }} +ThisBuild / githubWorkflowPublishTargetBranches := Seq.empty +ThisBuild / githubWorkflowBuild += WorkflowStep.Sbt(List("npmPackage"), name = Some("Package")) lazy val `cloudflare-public-hostname-lambda` = project .in(file(".")) diff --git a/project/plugins.sbt b/project/plugins.sbt index 5b3c061..7ca2661 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,6 @@ -addSbtPlugin("org.typelevel" % "sbt-typelevel-settings" % "0.8.0") -addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.0") +addSbtPlugin("org.typelevel" % "sbt-typelevel-github-actions" % "0.8.2") +addSbtPlugin("org.typelevel" % "sbt-typelevel-settings" % "0.8.2") +addSbtPlugin("org.typelevel" % "sbt-typelevel-mergify" % "0.8.2") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1") addSbtPlugin("org.typelevel" % "sbt-feral-lambda" % "0.3.1") addSbtPlugin("com.disneystreaming.smithy4s" % "smithy4s-sbt-codegen" % "0.18.42")