diff --git a/package-lock.json b/package-lock.json index 38ba6771e..86d5adeec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@apidevtools/json-schema-ref-parser": "^9.0.9", "@casl/ability": "^6.3.2", "@kubernetes/client-node": "^0.22.0", + "@linode/api-v4": "^0.129.0", "@types/json-schema": "^7.0.7", "@types/jsonwebtoken": "^9.0.1", "aws-sdk": "^2.879.0", @@ -95,6 +96,7 @@ "openapi-schema-validator": "3.0.3", "openapi-typescript": "5.3.0", "prettier": "2.6.2", + "proxyquire": "^2.1.3", "sinon": "8.1.1", "sinon-chai": "3.7.0", "standard-version": "9.5.0", @@ -223,7 +225,6 @@ "version": "7.17.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz", "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.4" }, @@ -1282,6 +1283,67 @@ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, + "node_modules/@linode/api-v4": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@linode/api-v4/-/api-v4-0.129.0.tgz", + "integrity": "sha512-he52JriqkSwZTaymWzLgjcF0kGnk5+tgLGS1qkUm254IfSxWzegM+7VaNEzpfUgJC+uKgdKclQhif1wrV26czw==", + "dependencies": { + "@linode/validation": "*", + "axios": "~1.7.4", + "ipaddr.js": "^2.0.0", + "yup": "^0.32.9" + } + }, + "node_modules/@linode/api-v4/node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/@linode/api-v4/node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@linode/api-v4/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@linode/validation": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@linode/validation/-/validation-0.55.0.tgz", + "integrity": "sha512-cEkNWq8NB4HAds1vD4u7Br6CPlkBBQ476chXfhV5V7xIn5U4YY8dZoG9tXZKQB4AgO0pV7YMS7v5qhafMEP2vg==", + "dependencies": { + "@types/yup": "^0.29.13", + "ipaddr.js": "^2.0.0", + "libphonenumber-js": "^1.10.6", + "yup": "^0.32.9" + } + }, + "node_modules/@linode/validation/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1768,8 +1830,7 @@ "node_modules/@types/lodash": { "version": "4.14.182", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", - "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", - "dev": true + "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==" }, "node_modules/@types/lowdb": { "version": "1.0.11", @@ -1950,6 +2011,11 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "node_modules/@types/yup": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.41.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.41.0.tgz", @@ -6532,6 +6598,19 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", + "dev": true, + "dependencies": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6663,9 +6742,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -8193,6 +8272,15 @@ "node": ">=8" } }, + "node_modules/is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -8878,6 +8966,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.13.tgz", + "integrity": "sha512-LIJmXxgs7o1njVZPcX5fkbtcFgDnXXPvJQQBH5Ho/8+r6BFlJaEbJ+bAiaUGaChWUhFtvawwdmXIOz4wZBANCg==" + }, "node_modules/lightship": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/lightship/-/lightship-6.8.0.tgz", @@ -9112,6 +9205,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -9883,6 +9981,12 @@ "node": ">=0.10.0" } }, + "node_modules/module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true + }, "node_modules/morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -9943,6 +10047,11 @@ "url": "https://github.com/sponsors/raouldeheer" } }, + "node_modules/nanoclone": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" + }, "node_modules/nanoid": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", @@ -11650,6 +11759,11 @@ "node": ">= 8" } }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11662,6 +11776,22 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "node_modules/proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "dependencies": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "node_modules/psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -14210,6 +14340,11 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -15362,6 +15497,23 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/yup": { + "version": "0.32.11", + "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", + "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/lodash": "^4.14.175", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "nanoclone": "^0.2.1", + "property-expr": "^2.0.4", + "toposort": "^2.0.2" + }, + "engines": { + "node": ">=10" + } } }, "dependencies": { @@ -15458,7 +15610,6 @@ "version": "7.17.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz", "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -16315,6 +16466,62 @@ "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==" }, + "@linode/api-v4": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@linode/api-v4/-/api-v4-0.129.0.tgz", + "integrity": "sha512-he52JriqkSwZTaymWzLgjcF0kGnk5+tgLGS1qkUm254IfSxWzegM+7VaNEzpfUgJC+uKgdKclQhif1wrV26czw==", + "requires": { + "@linode/validation": "*", + "axios": "~1.7.4", + "ipaddr.js": "^2.0.0", + "yup": "^0.32.9" + }, + "dependencies": { + "axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "requires": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==" + } + } + }, + "@linode/validation": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@linode/validation/-/validation-0.55.0.tgz", + "integrity": "sha512-cEkNWq8NB4HAds1vD4u7Br6CPlkBBQ476chXfhV5V7xIn5U4YY8dZoG9tXZKQB4AgO0pV7YMS7v5qhafMEP2vg==", + "requires": { + "@types/yup": "^0.29.13", + "ipaddr.js": "^2.0.0", + "libphonenumber-js": "^1.10.6", + "yup": "^0.32.9" + }, + "dependencies": { + "ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==" + } + } + }, "@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -16751,8 +16958,7 @@ "@types/lodash": { "version": "4.14.182", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz", - "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==", - "dev": true + "integrity": "sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==" }, "@types/lowdb": { "version": "1.0.11", @@ -16933,6 +17139,11 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", "dev": true }, + "@types/yup": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==" + }, "@typescript-eslint/eslint-plugin": { "version": "5.41.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.41.0.tgz", @@ -20458,6 +20669,16 @@ "flat-cache": "^3.0.4" } }, + "fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha512-tcgI872xXjwFF4xgQmLxi76GnwJG3g/3isB1l4/G5Z4zrbddGpBjqZCO9oEAcB5wX0Hj/5iQB3toxfO7in1hHA==", + "dev": true, + "requires": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -20569,9 +20790,9 @@ "dev": true }, "follow-redirects": { - "version": "1.14.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz", - "integrity": "sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==" + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" }, "for-in": { "version": "1.0.2", @@ -21690,6 +21911,12 @@ "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", "dev": true }, + "is-object": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz", + "integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==", + "dev": true + }, "is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -22230,6 +22457,11 @@ "type-check": "~0.4.0" } }, + "libphonenumber-js": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.13.tgz", + "integrity": "sha512-LIJmXxgs7o1njVZPcX5fkbtcFgDnXXPvJQQBH5Ho/8+r6BFlJaEbJ+bAiaUGaChWUhFtvawwdmXIOz4wZBANCg==" + }, "lightship": { "version": "6.8.0", "resolved": "https://registry.npmjs.org/lightship/-/lightship-6.8.0.tgz", @@ -22405,6 +22637,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -23000,6 +23237,12 @@ "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", "dev": true }, + "module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha512-pEk4ECWQXV6z2zjhRZUongnLJNUeGQJ3w6OQ5ctGwD+i5o93qjRQUk2Rt6VdNeu3sEP0AB4LcfvdebpxBRVr4g==", + "dev": true + }, "morgan": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", @@ -23049,6 +23292,11 @@ "integrity": "sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg==", "dev": true }, + "nanoclone": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" + }, "nanoid": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.1.tgz", @@ -24381,6 +24629,11 @@ "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", "dev": true }, + "property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -24390,6 +24643,22 @@ "ipaddr.js": "1.9.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, + "proxyquire": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.3.tgz", + "integrity": "sha512-BQWfCqYM+QINd+yawJz23tbBM40VIGXOdDw3X344KcclI/gtBbdWF6SlQ4nK/bYhF9d27KYug9WzljHC6B9Ysg==", + "dev": true, + "requires": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.1", + "resolve": "^1.11.1" + } + }, "psl": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", @@ -26400,6 +26669,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "touch": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", @@ -27271,6 +27545,20 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "yup": { + "version": "0.32.11", + "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", + "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/lodash": "^4.14.175", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "nanoclone": "^0.2.1", + "property-expr": "^2.0.4", + "toposort": "^2.0.2" + } } } } diff --git a/package.json b/package.json index a46781aee..c68586f2f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@apidevtools/json-schema-ref-parser": "^9.0.9", "@casl/ability": "^6.3.2", "@kubernetes/client-node": "^0.22.0", + "@linode/api-v4": "^0.129.0", "@types/json-schema": "^7.0.7", "@types/jsonwebtoken": "^9.0.1", "aws-sdk": "^2.879.0", @@ -95,6 +96,7 @@ "openapi-schema-validator": "3.0.3", "openapi-typescript": "5.3.0", "prettier": "2.6.2", + "proxyquire": "^2.1.3", "sinon": "8.1.1", "sinon-chai": "3.7.0", "standard-version": "9.5.0", diff --git a/src/api/objwizard.ts b/src/api/objwizard.ts index f40004c60..64e3d2dad 100644 --- a/src/api/objwizard.ts +++ b/src/api/objwizard.ts @@ -5,13 +5,6 @@ import { ObjWizard, OpenApiRequestExt } from 'src/otomi-models' const debug = Debug('otomi:api:objwizard') export default function (): OperationHandlerArray { - const get: Operation = [ - ({ otomi }: OpenApiRequestExt, res): void => { - debug('getObjWizard') - const v = otomi.getObjWizard() - res.json(v) - }, - ] const post: Operation = [ async ({ otomi, body }: OpenApiRequestExt, res): Promise => { debug('createObjWizard') @@ -20,7 +13,6 @@ export default function (): OperationHandlerArray { }, ] const api = { - get, post, } return api diff --git a/src/openapi/api.yaml b/src/openapi/api.yaml index 7bbb600b8..40b0ef64e 100644 --- a/src/openapi/api.yaml +++ b/src/openapi/api.yaml @@ -1418,17 +1418,6 @@ paths: $ref: '#/components/schemas/SettingsInfo' '/objwizard': - get: - operationId: getObjWizard - x-aclSchema: ObjWizard - responses: - <<: *DefaultGetResponses - '200': - description: Successfully obtained obj wizard configuration - content: - application/json: - schema: - $ref: '#/components/schemas/ObjWizard' post: operationId: createObjWizard description: Create a obj wizard configuration diff --git a/src/openapi/objwizard.yaml b/src/openapi/objwizard.yaml index ab94cb4fe..4b472f3e0 100644 --- a/src/openapi/objwizard.yaml +++ b/src/openapi/objwizard.yaml @@ -13,4 +13,7 @@ ObjWizard: type: string description: The Linode API token for creating object storage access key and buckets. $ref: definitions.yaml#/wordCharacterPattern + regionId: + type: string + description: The region where the object storage buckets will be created. type: object diff --git a/src/openapi/session.yaml b/src/openapi/session.yaml index 8af352ffa..c8ab4398a 100644 --- a/src/openapi/session.yaml +++ b/src/openapi/session.yaml @@ -20,10 +20,29 @@ Session: # readOnly: true defaultPlatformAdminEmail: type: string - objStorageApps: - type: array - items: - type: object + objectStorage: + type: object + properties: + showWizard: + type: boolean + objStorageApps: + type: array + items: + type: object + properties: + appId: + type: string + required: + type: boolean + objStorageRegions: + type: array + items: + type: object + properties: + id: + type: string + label: + type: string versions: type: object # readOnly: true diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 608e4b5fe..84caf23a4 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -3,6 +3,7 @@ import * as k8s from '@kubernetes/client-node' import { V1ObjectReference } from '@kubernetes/client-node' import Debug from 'debug' +import { ObjectStorageKeyRegions, getRegions } from '@linode/api-v4' import { emptyDir, pathExists, unlink } from 'fs-extra' import { readFile, readdir, writeFile } from 'fs/promises' import { generate as generatePassword } from 'generate-password' @@ -72,7 +73,7 @@ import { validateBackupFields } from './utils/backupUtils' import { getPolicies } from './utils/policiesUtils' import { encryptSecretItem } from './utils/sealedSecretUtils' import { getKeycloakUsers } from './utils/userUtils' -import { createObjectStorageAccessKey, createObjectStorageBucket, getClusterRegion } from './utils/wizardUtils' +import { ObjectStorageClient } from './utils/wizardUtils' import { fetchWorkloadCatalog } from './utils/workloadUtils' interface ExcludedApp extends App { @@ -276,45 +277,51 @@ export default class OtomiStack { return settingsInfo } - getObjWizard(): ObjWizard { - const { obj } = this.getSettings(['obj']) - return { showWizard: obj?.showWizard ?? true } as ObjWizard - } - async createObjWizard(data: ObjWizard): Promise { const { obj } = this.getSettings(['obj']) const settingsdata = { obj: { ...obj, showWizard: data.showWizard } } - if (data?.apiToken) { + if (data?.apiToken && data?.regionId) { const { cluster } = this.getSettings(['cluster']) - const clusterId = cluster?.name?.replace('aplinstall', '') - const clusterRegion = await getClusterRegion(data.apiToken, clusterId) - const { access_key, secret_key, regions } = await createObjectStorageAccessKey( - data.apiToken, - clusterId, - clusterRegion, - ) - const { s3_endpoint } = regions.find((region) => region.id === clusterRegion) - const objStorageRegion = s3_endpoint.split('.')[0] as string - const buckets = ['cnpg', 'harbor', 'loki', 'tempo', 'velero', 'gitea', 'thanos'] - for (const bucket of buckets) { - const res = await createObjectStorageBucket(data.apiToken, `lke${clusterId}-${bucket}`, clusterRegion) - debug(`${res.label} is created!`) + const lkeClusterId = Number(cluster?.name?.replace('aplinstall', '')) + if (!lkeClusterId) throw new OtomiError('Cluster ID is not found in the cluster name') + const bucketNames = { + cnpg: `lke${lkeClusterId}-cnpg`, + harbor: `lke${lkeClusterId}-harbor`, + loki: `lke${lkeClusterId}-loki`, + tempo: `lke${lkeClusterId}-tempo`, + velero: `lke${lkeClusterId}-velero`, + gitea: `lke${lkeClusterId}-gitea`, + thanos: `lke${lkeClusterId}-thanos`, } + const objectStorageClient = new ObjectStorageClient(data.apiToken) + // Create object storage buckets + for (const bucket in bucketNames) { + const bucketLabel = await objectStorageClient.createObjectStorageBucket( + bucketNames[bucket] as string, + data.regionId, + ) + debug(`${bucketLabel} bucket is created.`) + } + // Create object storage keys + const { access_key, secret_key, regions } = await objectStorageClient.createObjectStorageKey( + lkeClusterId, + data.regionId, + Object.values(bucketNames), + ) + // The data.regionId (for example 'eu-central') does not include the zone. + // However, we need to add the region with the zone suffix (for example 'eu-central-1') in the object storage values. + // Therefore, we need to extract the region with the zone suffix from the s3_endpoint. + const { s3_endpoint } = regions.find((region) => region.id === data.regionId) as ObjectStorageKeyRegions + const [objStorageRegion] = s3_endpoint.split('.') + debug(`Object Storage keys are created.`) + // Modify object storage settings settingsdata.obj = { showWizard: false, provider: { type: 'linode', linode: { accessKeyId: access_key, - buckets: { - cnpg: `lke${clusterId}-cnpg`, - harbor: `lke${clusterId}-harbor`, - loki: `lke${clusterId}-loki`, - tempo: `lke${clusterId}-tempo`, - velero: `lke${clusterId}-velero`, - gitea: `lke${clusterId}-gitea`, - thanos: `lke${clusterId}-thanos`, - }, + buckets: bucketNames, region: objStorageRegion, secretAccessKey: secret_key, }, @@ -323,6 +330,7 @@ export default class OtomiStack { } await this.editSettings(settingsdata as Settings, 'obj') await this.doDeployment() + debug('Object storage settings have been configured.') } getSettings(keys?: string[]): Settings { @@ -1889,6 +1897,13 @@ export default class OtomiStack { const rootStack = await getSessionStack() const valuesSchema = await getValuesSchema() const currentSha = rootStack.repo.commitSha + const { obj } = this.getSettings(['obj']) + const regions = await getRegions() + const objStorageRegions = + regions.data + .filter((region) => region.capabilities.includes('Object Storage')) + .map(({ id, label }) => ({ id, label })) + .sort((a, b) => a.label.localeCompare(b.label)) || [] const data: Session = { ca: env.CUSTOM_ROOT_CA, core: this.getCore() as Record, @@ -1897,7 +1912,11 @@ export default class OtomiStack { inactivityTimeout: env.EDITOR_INACTIVITY_TIMEOUT, user: user as SessionUser, defaultPlatformAdminEmail: env.DEFAULT_PLATFORM_ADMIN_EMAIL, - objStorageApps: env.OBJ_STORAGE_APPS, + objectStorage: { + showWizard: obj?.showWizard ?? true, + objStorageApps: env.OBJ_STORAGE_APPS, + objStorageRegions, + }, versions: { core: env.VERSIONS.core, api: env.VERSIONS.api ?? process.env.npm_package_version, diff --git a/src/utils/wizardUtils.test.ts b/src/utils/wizardUtils.test.ts new file mode 100644 index 000000000..00f60d8f7 --- /dev/null +++ b/src/utils/wizardUtils.test.ts @@ -0,0 +1,157 @@ +import { ObjectStorageKey } from '@linode/api-v4' +import { expect } from 'chai' +import proxyquire from 'proxyquire' +import sinon from 'sinon' +import { OtomiError } from 'src/error' + +describe('ObjectStorageClient', () => { + let ObjectStorageClient: any + let setTokenStub: sinon.SinonStub + let getKubernetesClusterStub: sinon.SinonStub + let createObjectStorageKeysStub: sinon.SinonStub + let createBucketStub: sinon.SinonStub + let client: any + const clusterId = 12345 + + beforeEach(() => { + setTokenStub = sinon.stub() + getKubernetesClusterStub = sinon.stub() + createObjectStorageKeysStub = sinon.stub() + createBucketStub = sinon.stub() + + // Use proxyquire to mock the module imports + const module = proxyquire('./wizardUtils.ts', { + '@linode/api-v4': { + setToken: setTokenStub, + getKubernetesCluster: getKubernetesClusterStub, + createObjectStorageKeys: createObjectStorageKeysStub, + createBucket: createBucketStub, + }, + }) + + ObjectStorageClient = module.ObjectStorageClient + client = new ObjectStorageClient('test-token') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('constructor', () => { + it('should set token when initialized', () => { + expect(setTokenStub.calledOnceWith('test-token')).to.be.true + }) + }) + + describe('createObjectStorageBucket', () => { + const label = 'test-bucket' + const region = 'us-east' + + it('should successfully create bucket', async () => { + const mockResponse = { label: 'test-bucket' } + createBucketStub.resolves(mockResponse) + + const result = await client.createObjectStorageBucket(label, region) + + expect( + createBucketStub.calledOnceWith({ + label, + region, + }), + ).to.be.true + expect(result).to.equal('test-bucket') + }) + + it('should throw OtomiError when bucket creation fails', async () => { + const mockError = { + response: { + status: 401, + data: { errors: [{ reason: 'Your OAuth token is not authorized to use this endpoint' }] }, + }, + } + createBucketStub.rejects(mockError) + + try { + await client.createObjectStorageBucket(label, region) + expect.fail('Should have thrown an error') + } catch (error) { + expect(error).to.be.instanceOf(OtomiError) + expect(error.publicMessage).to.equal('Your OAuth token is not authorized to use this endpoint') + expect(error.code).to.equal(401) + } + }) + + it('should throw OtomiError with default message when no specific error info', async () => { + const mockError = { + response: { + status: 500, + }, + } + createBucketStub.rejects(mockError) + + try { + await client.createObjectStorageBucket(label, region) + expect.fail('Should have thrown an error') + } catch (error) { + expect(error).to.be.instanceOf(OtomiError) + expect(error.publicMessage).to.equal('Error creating object storage bucket') + expect(error.code).to.equal(500) + } + }) + }) + + describe('createObjectStorageKey', () => { + const region = 'us-east' + const bucketNames = ['bucket1', 'bucket2'] + let clock: sinon.SinonFakeTimers + + beforeEach(() => { + const fixedDate = new Date('2024-01-01T12:00:00.000Z') + clock = sinon.useFakeTimers(fixedDate.getTime()) + }) + + afterEach(() => { + clock.restore() + }) + + it('should successfully create object storage keys', async () => { + const mockResponse: Pick = { + access_key: 'test-access-key', + secret_key: 'test-secret-key', + regions: [{ id: 'us-east', s3_endpoint: 'us-east-1.linodeobjects.com' }], + } + createObjectStorageKeysStub.resolves(mockResponse) + const result = await client.createObjectStorageKey(clusterId, region, bucketNames) + + expect(createObjectStorageKeysStub.calledOnce).to.be.true + expect(createObjectStorageKeysStub.firstCall.args[0]).to.deep.equal({ + label: `lke${clusterId}-key-1704110400000`, + regions: [region], + bucket_access: [ + { bucket_name: 'bucket1', permissions: 'read_write', region }, + { bucket_name: 'bucket2', permissions: 'read_write', region }, + ], + }) + expect(result).to.deep.equal(mockResponse) + }) + + it('should throw OtomiError when keys creation fails', async () => { + const mockError = { + response: { + data: { errors: [{ reason: 'Your OAuth token is not authorized to use this endpoint' }] }, + status: 401, + }, + } + createObjectStorageKeysStub.rejects(mockError) + + try { + await client.createObjectStorageKey(clusterId, region, bucketNames) + expect.fail('Should have thrown an error') + } catch (error) { + expect(error).to.be.instanceOf(OtomiError) + expect(error.publicMessage).to.equal('Your OAuth token is not authorized to use this endpoint') + expect(error.code).to.equal(401) + } + }) + }) +}) diff --git a/src/utils/wizardUtils.ts b/src/utils/wizardUtils.ts index acd99354b..376bab5d1 100644 --- a/src/utils/wizardUtils.ts +++ b/src/utils/wizardUtils.ts @@ -1,55 +1,57 @@ -import axios from 'axios' +import { createBucket, createObjectStorageKeys, ObjectStorageKey, setToken } from '@linode/api-v4' import { OtomiError } from 'src/error' -const axiosInstance = (linodeApiToken) => - axios.create({ - baseURL: 'https://api.linode.com/v4', - headers: { - Authorization: `Bearer ${linodeApiToken}`, - 'Content-Type': 'application/json', - }, - }) +export class ObjectStorageClient { + constructor(private apiToken: string) { + this.setToken() + } -export const getClusterRegion = async (linodeApiToken, clusterId) => { - try { - const res = await axiosInstance(linodeApiToken).get(`/lke/clusters/${clusterId}`) - return res.data.region - } catch (err) { - const error = new OtomiError(err.response.statusText ?? 'Error getting cluster region') - error.code = err.response.status ?? 500 - error.publicMessage = err.response.data.errors[0].reason ?? '' - throw error + private setToken() { + setToken(this.apiToken) } -} -export const createObjectStorageAccessKey = async (linodeApiToken, clusterId, region) => { - const dateTime = new Date().toISOString().slice(0, 19).replace('T', '-') - try { - const res = await axiosInstance(linodeApiToken).post('/object-storage/keys', { - label: `lke${clusterId}-key-${dateTime}`, - region, - permissions: 'read_write', - }) - return res.data - } catch (err) { - const error = new OtomiError(err.response.statusText ?? 'Error creating object storage access key') - error.code = err.response.status ?? 500 - error.publicMessage = err.response.data.errors[0].reason ?? '' - throw error + public async createObjectStorageBucket(label: string, region: string): Promise { + try { + const bucket = await createBucket({ + label, + region, + }) + return bucket.label + } catch (err) { + const error = new OtomiError( + err.response?.data?.errors?.[0]?.reason ?? err.response?.statusText ?? 'Error creating object storage bucket', + ) + error.code = err.response?.status ?? 500 + throw error + } } -} -export const createObjectStorageBucket = async (linodeApiToken, label, region) => { - try { - const res = await axiosInstance(linodeApiToken).post('/object-storage/buckets', { - label, + public async createObjectStorageKey( + lkeClusterId: number, + region: string, + bucketNames: string[], + ): Promise> { + const timestamp = new Date().getTime() + const bucketAccesses: any[] = bucketNames.map((bucketName) => ({ + bucket_name: bucketName, + permissions: 'read_write', region, - }) - return res.data - } catch (err) { - const error = new OtomiError(err.response.statusText ?? 'Error creating object storage bucket') - error.code = err.response.status ?? 500 - error.publicMessage = err.response.data.errors[0].reason ?? '' - throw error + })) + try { + const objectStorageKeys = await createObjectStorageKeys({ + label: `lke${lkeClusterId}-key-${timestamp}`, + regions: [region], + bucket_access: bucketAccesses, + }) + return objectStorageKeys + } catch (err) { + const error = new OtomiError( + err.response?.data?.errors?.[0]?.reason ?? + err.response?.statusText ?? + 'Error creating object storage access key', + ) + error.code = err.response?.status ?? 500 + throw error + } } }