diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d497ba7..d1bad21 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,48 +1,48 @@ -## Issue - -ISSUE > [ISSUE NAME](https://github.com/users/prathikanand7/projects/2?pane=issue&itemId=) -​ - -## Description - -Summarise what this PR does and why it is needed. Include any relevant context that a reviewer would not get from just reading the code diff. - -## Type of change - - - -- [ ] Bug fix -- [ ] New feature -- [ ] Refactor / cleanup -- [ ] Documentation update -- [ ] Configuration / infrastructure change -- [ ] Other: - -## How has this been tested? - -Describe how you verified that your changes work correctly. Include any relevant commands, test cases, or environments used. If no testing was needed, explain why. - -- [ ] Unit tests -- [ ] Manual testing -- [ ] No testing required - -## Notes for reviewers - -Anything you want to flag for reviewers such as areas of uncertainty, known limitations, deliberate tradeoffs, or specific things you would like feedback on. Leave blank if nothing needs highlighting. - -## Checklist - -Go through each item before marking the PR as ready for review. - -- [ ] I filled in the releated issue link -- [ ] My PR has the name of the branch -- [ ] My code follows the project's coding style -- [ ] I have reviewed my own changes before requesting review -- [ ] I have added or updated relevant documentation -- [ ] My changes do not introduce new warnings or errors -- [ ] I have not committed any secrets, credentials, or sensitive values - -## Related issues - -Link any issues this PR resolves or is related to. -Closes # +## Issue + +ISSUE > [ISSUE NAME](https://github.com/users/prathikanand7/projects/2?pane=issue&itemId=) +​ + +## Description + +Summarise what this PR does and why it is needed. Include any relevant context that a reviewer would not get from just reading the code diff. + +## Type of change + + + +- [ ] Bug fix +- [ ] New feature +- [ ] Refactor / cleanup +- [ ] Documentation update +- [ ] Configuration / infrastructure change +- [ ] Other: + +## How has this been tested? + +Describe how you verified that your changes work correctly. Include any relevant commands, test cases, or environments used. If no testing was needed, explain why. + +- [ ] Unit tests +- [ ] Manual testing +- [ ] No testing required + +## Notes for reviewers + +Anything you want to flag for reviewers such as areas of uncertainty, known limitations, deliberate tradeoffs, or specific things you would like feedback on. Leave blank if nothing needs highlighting. + +## Checklist + +Go through each item before marking the PR as ready for review. + +- [ ] I filled in the releated issue link +- [ ] My PR has the name of the branch +- [ ] My code follows the project's coding style +- [ ] I have reviewed my own changes before requesting review +- [ ] I have added or updated relevant documentation +- [ ] My changes do not introduce new warnings or errors +- [ ] I have not committed any secrets, credentials, or sensitive values + +## Related issues + +Link any issues this PR resolves or is related to. +Closes # diff --git a/.github/workflows/e2e-notebook-deploy-and-run.yml b/.github/workflows/e2e-notebook-deploy-and-run.yml index b42c308..c39b184 100644 --- a/.github/workflows/e2e-notebook-deploy-and-run.yml +++ b/.github/workflows/e2e-notebook-deploy-and-run.yml @@ -1,5 +1,5 @@ name: Notebook E2E Deploy and Run -# Deploy the entire infrastructure and execute a notebook end-to-end to verify everything works together as expected. +# Deploy the entire infrastructure and execute a notebook end-to-end to verify everything works together as expected. on: workflow_run: diff --git a/.github/workflows/smoke-test-worker-containerization.yml b/.github/workflows/smoke-test-worker-containerization.yml index fc78f16..af7e1dd 100644 --- a/.github/workflows/smoke-test-worker-containerization.yml +++ b/.github/workflows/smoke-test-worker-containerization.yml @@ -1,5 +1,5 @@ name: Smoke Test Worker Containerization -# This workflow builds the worker and runs a smoke test to verify that the containerization is working correctly. +# This workflow builds the worker and runs a smoke test to verify that the containerization is working correctly. on: push: @@ -41,4 +41,4 @@ jobs: - name: Verify Worker Containerisation run: | docker run --rm r-notebook-worker:test \ - python -c "import os, boto3, papermill; assert os.path.exists('/app/worker.py'); print('worker-containerisation-ok')" \ No newline at end of file + python -c "import os, boto3, papermill; assert os.path.exists('/app/worker.py'); print('worker-containerisation-ok')" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d6b2545 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: check-merge-conflict + - id: check-json + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=1500] + - id: detect-private-key + - id: detect-aws-credentials + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.0 + hooks: + - id: ruff + args: [--fix] + files: ^(worker/.*\.py|lifewatch_batch_platform/terraform/(backend_lambdas|client_requests)/.*\.py)$ + - id: ruff-format + files: ^(worker/.*\.py|lifewatch_batch_platform/terraform/(backend_lambdas|client_requests)/.*\.py)$ + + - repo: https://github.com/kynan/nbstripout + rev: 0.8.1 + hooks: + - id: nbstripout + files: ^demo_input/.*\.ipynb$ + + - repo: local + hooks: + - id: terraform-fmt + name: Terraform fmt + entry: terraform fmt -recursive + language: system + pass_filenames: false + files: ^(lifewatch_batch_platform/terraform|terraform-bootstrap)/.*\.tf$ + + - id: frontend-eslint + name: Frontend ESLint + entry: npm --prefix frontend run lint + language: system + pass_filenames: false + files: ^frontend/(src/.*\.(ts|tsx)|package\.json|tsconfig\.json|vite\.config\.ts)$ diff --git a/compress_lambdas.ps1 b/compress_lambdas.ps1 index adf4139..17b42f2 100644 --- a/compress_lambdas.ps1 +++ b/compress_lambdas.ps1 @@ -11,4 +11,4 @@ Compress-Archive -Path "$SourceDir\status.py", "$SourceDir\handle_cors.py" -Dest Compress-Archive -Path "$SourceDir\lambda_function.py", "$SourceDir\handle_cors.py" -DestinationPath "$TargetDir\lambda.zip" -Force Compress-Archive -Path "$SourceDir\history_list.py", "$SourceDir\handle_cors.py" -DestinationPath "$TargetDir\history_list_lambda.zip" -Force -Write-Host "Lambdas compressed to $TargetDir." \ No newline at end of file +Write-Host "Lambdas compressed to $TargetDir." diff --git a/compress_lambdas.sh b/compress_lambdas.sh index 589bd87..ccc1d76 100644 --- a/compress_lambdas.sh +++ b/compress_lambdas.sh @@ -27,4 +27,4 @@ zip -j "$TARGET_DIR/history_list_lambda.zip" \ "$SOURCE_DIR/history_list.py" \ "$SOURCE_DIR/handle_cors.py" -echo "Lambdas compressed to $TARGET_DIR." \ No newline at end of file +echo "Lambdas compressed to $TARGET_DIR." diff --git a/demo_input/Data_cleaning.ipynb b/demo_input/Data_cleaning.ipynb index 233c341..3ef92be 100644 --- a/demo_input/Data_cleaning.ipynb +++ b/demo_input/Data_cleaning.ipynb @@ -3,7 +3,7 @@ { "cell_type": "code", "execution_count": null, - "id": "477a0544-ba44-4649-87a2-4448aa7bafed", + "id": "0", "metadata": { "tags": [ "parameters" @@ -39,7 +39,7 @@ { "cell_type": "code", "execution_count": null, - "id": "e9a7a47a-31d5-4c61-9bae-183b1d07d7d6", + "id": "1", "metadata": { "vscode": { "languageId": "r" @@ -111,7 +111,7 @@ { "cell_type": "code", "execution_count": null, - "id": "c030c326-67b4-43d9-9213-8d58857b5d7f", + "id": "2", "metadata": { "vscode": { "languageId": "r" @@ -158,7 +158,7 @@ { "cell_type": "code", "execution_count": null, - "id": "14c595e0-33ba-4aae-890f-ad44c397429a", + "id": "3", "metadata": { "vscode": { "languageId": "r" @@ -260,7 +260,7 @@ { "cell_type": "code", "execution_count": null, - "id": "19e93b1c-73ab-44cd-9a54-4674d75a6fd9", + "id": "4", "metadata": { "vscode": { "languageId": "r" @@ -556,7 +556,7 @@ { "cell_type": "code", "execution_count": null, - "id": "b0c83712-351d-48de-88df-509c1f7e1c82", + "id": "5", "metadata": { "vscode": { "languageId": "r" diff --git a/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_A.ipynb b/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_A.ipynb index c1901de..1f6987c 100644 --- a/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_A.ipynb +++ b/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_A.ipynb @@ -3,6 +3,7 @@ { "cell_type": "code", "execution_count": null, + "id": "0", "metadata": {}, "outputs": [], "source": [ @@ -14,6 +15,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1", "metadata": {}, "outputs": [], "source": [ diff --git a/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_B.ipynb b/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_B.ipynb index f466cf7..1f20cf4 100644 --- a/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_B.ipynb +++ b/demo_input/lightweight-notebooks/Notebook_Lightweight_Example_B.ipynb @@ -3,6 +3,7 @@ { "cell_type": "code", "execution_count": null, + "id": "0", "metadata": {}, "outputs": [], "source": [ @@ -14,6 +15,7 @@ { "cell_type": "code", "execution_count": null, + "id": "1", "metadata": {}, "outputs": [], "source": [ diff --git a/demo_input/s3-notebook/Laserfarm.ipynb b/demo_input/s3-notebook/Laserfarm.ipynb index 10d3c29..b5a3410 100644 --- a/demo_input/s3-notebook/Laserfarm.ipynb +++ b/demo_input/s3-notebook/Laserfarm.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "5fe9c96fbfc26db8", + "id": "0", "metadata": {}, "source": [ "# Laserfarm: LiDAR point cloud analysis for macro-ecology" @@ -10,7 +10,7 @@ }, { "cell_type": "markdown", - "id": "dc69fbda9063f031", + "id": "1", "metadata": {}, "source": [ "## Configuration" @@ -18,7 +18,7 @@ }, { "cell_type": "markdown", - "id": "f5310e2ad322cfc", + "id": "2", "metadata": {}, "source": [ "### User parameters\n", @@ -29,7 +29,7 @@ { "cell_type": "code", "execution_count": null, - "id": "58839cc4874477e6", + "id": "3", "metadata": {}, "outputs": [], "source": [ @@ -61,7 +61,7 @@ }, { "cell_type": "markdown", - "id": "7b3045b53290a911", + "id": "4", "metadata": {}, "source": [ "### Dependencies\n", @@ -72,7 +72,7 @@ { "cell_type": "code", "execution_count": null, - "id": "11a47e25462468", + "id": "5", "metadata": {}, "outputs": [], "source": [ @@ -92,7 +92,7 @@ }, { "cell_type": "markdown", - "id": "3d5f56b8f9d91414", + "id": "6", "metadata": {}, "source": [ "### Global configuration\n", @@ -103,7 +103,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7473be2a344c442b", + "id": "7", "metadata": {}, "outputs": [], "source": [ @@ -126,7 +126,7 @@ }, { "cell_type": "markdown", - "id": "58d295ddd79bb88a", + "id": "8", "metadata": {}, "source": [ "## Workflow steps" @@ -134,7 +134,7 @@ }, { "cell_type": "markdown", - "id": "3b548ac41ee18a0e", + "id": "9", "metadata": {}, "source": [ "### Fetch laz files from cloud storage\n", @@ -145,7 +145,7 @@ { "cell_type": "code", "execution_count": null, - "id": "61b32de4ea0b4d69", + "id": "10", "metadata": {}, "outputs": [], "source": [ @@ -192,7 +192,7 @@ }, { "cell_type": "markdown", - "id": "5954624ee3b88f3e", + "id": "11", "metadata": {}, "source": [ "### Split big files\n", @@ -203,7 +203,7 @@ { "cell_type": "code", "execution_count": null, - "id": "bf5e44a3396c387a", + "id": "12", "metadata": {}, "outputs": [], "source": [ @@ -277,7 +277,7 @@ }, { "cell_type": "markdown", - "id": "83b9365f25ca6d66", + "id": "13", "metadata": {}, "source": [ "### Retile laz files\n", @@ -288,7 +288,7 @@ { "cell_type": "code", "execution_count": null, - "id": "36d5709cb7bc89be", + "id": "14", "metadata": {}, "outputs": [], "source": [ @@ -341,7 +341,7 @@ }, { "cell_type": "markdown", - "id": "1716337f5b3e5eef", + "id": "15", "metadata": {}, "source": [ "### Unique tiles\n", @@ -352,7 +352,7 @@ { "cell_type": "code", "execution_count": null, - "id": "790214ee91c7a214", + "id": "16", "metadata": {}, "outputs": [], "source": [ @@ -366,7 +366,7 @@ }, { "cell_type": "markdown", - "id": "d6006967dc3de6d2", + "id": "17", "metadata": {}, "source": [ "### Extract features from tiles\n", @@ -377,7 +377,7 @@ { "cell_type": "code", "execution_count": null, - "id": "d05738c65aab3dba", + "id": "18", "metadata": {}, "outputs": [], "source": [ @@ -449,7 +449,7 @@ }, { "cell_type": "markdown", - "id": "edf5fd201d851c43", + "id": "19", "metadata": {}, "source": [ "### Save GeoTIFF\n", @@ -460,7 +460,7 @@ { "cell_type": "code", "execution_count": null, - "id": "55d538f14c000efd", + "id": "20", "metadata": {}, "outputs": [], "source": [ @@ -501,7 +501,7 @@ }, { "cell_type": "markdown", - "id": "6cca187dc26066df", + "id": "21", "metadata": {}, "source": [ "### Create figures" @@ -510,7 +510,7 @@ { "cell_type": "code", "execution_count": null, - "id": "77de498937143788", + "id": "22", "metadata": {}, "outputs": [], "source": [ @@ -559,7 +559,7 @@ { "cell_type": "code", "execution_count": null, - "id": "469af21714b381c6", + "id": "23", "metadata": {}, "outputs": [], "source": [] diff --git a/demo_input/s3-notebook/environment.yaml b/demo_input/s3-notebook/environment.yaml index f401bbc..8266e54 100644 --- a/demo_input/s3-notebook/environment.yaml +++ b/demo_input/s3-notebook/environment.yaml @@ -7,7 +7,7 @@ dependencies: - jq>=1.8 - minio>=7.2.15 - pip>=25.0.1 - - python>=3.10 + - python>=3.10 - asyncssh - cxx-compiler - distro @@ -37,4 +37,4 @@ dependencies: - pip: - SecretsProvider - git+https://github.com/QCDIS/Laserfarm.git - - git+https://github.com/QCDIS/laserchicken.git \ No newline at end of file + - git+https://github.com/QCDIS/laserchicken.git diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..3b7fc79 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,28 @@ +import js from "@eslint/js"; +import globals from "globals"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: ["dist"] }, + { + files: ["**/*.{ts,tsx}"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + "react-hooks": reactHooks, + "react-refresh": reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +); diff --git a/frontend/index.html b/frontend/index.html index 964573d..d2a1a5f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,4 +14,3 @@ - diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 37673ae..51beaaf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,10 +14,16 @@ "react-icons": "^5.6.0" }, "devDependencies": { + "@eslint/js": "^9.22.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.7.1", + "eslint": "^9.22.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", "typescript": "^5.7.2", + "typescript-eslint": "^8.26.0", "vite": "^6.0.5" } }, @@ -463,6 +469,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1053,6 +1253,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1081,176 +1287,1115 @@ "@types/react": "^18.0.0" } }, - "node_modules/@vitejs/plugin-react-swc": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", - "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", "dev": true, - "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.27", - "@swc/core": "^1.12.11" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6 || ^7" + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/attr-accept": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", - "license": "MIT", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "engines": { - "node": ">=4" + "node": ">= 4" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" }, "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", "dev": true, - "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, "engines": { - "node": ">=12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/file-selector": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", - "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", - "license": "MIT", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "dev": true, "dependencies": { - "tslib": "^2.7.0" + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" }, "engines": { - "node": ">= 12" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "bin": { - "loose-envify": "cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "dev": true, "engines": { - "node": ">=0.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", "dev": true, - "license": "ISC" - }, + "dependencies": { + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -1293,6 +2438,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -1304,6 +2458,15 @@ "react-is": "^16.13.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1361,6 +2524,15 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -1415,6 +2587,39 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1425,6 +2630,30 @@ "node": ">=0.10.0" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1442,12 +2671,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1462,6 +2715,38 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", @@ -1536,6 +2821,42 @@ "optional": true } } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index dc03915..3480e69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,9 +19,15 @@ "react-icons": "^5.6.0" }, "devDependencies": { + "@eslint/js": "^9.22.0", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react-swc": "^3.7.1", + "eslint": "^9.22.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript-eslint": "^8.26.0", "typescript": "^5.7.2", "vite": "^6.0.5" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 487604a..ba59d2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -780,4 +780,4 @@ export const App: React.FC = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/DropZone.tsx b/frontend/src/components/DropZone.tsx index 4dade82..651b8d5 100644 --- a/frontend/src/components/DropZone.tsx +++ b/frontend/src/components/DropZone.tsx @@ -139,4 +139,4 @@ export const DropZone: React.FC = ({ label, files, setFiles, onFi )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index c03e04c..ea54c0f 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -11,4 +11,4 @@ const FilePreview: React.FC = ({ file }) => { return null; }; -export default FilePreview; \ No newline at end of file +export default FilePreview; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7b33a73..8f485fe 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,4 +8,3 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( , ); - diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 768f288..31529c6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -594,7 +594,7 @@ select.form-control-styled { } /* ══════════════════════════════════════════════════════════════ - BUTTONS + BUTTONS ══════════════════════════════════════════════════════════════ */ .btn-neon { position: relative; @@ -1896,4 +1896,4 @@ html[data-theme='light'] select.form-control-styled option:focus { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } -} \ No newline at end of file +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 3d22abe..e7371ca 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,4 +10,3 @@ export default defineConfig({ port: 4173, }, }); - diff --git a/job_profiles.json b/job_profiles.json index 706cbcd..5f267a1 100644 --- a/job_profiles.json +++ b/job_profiles.json @@ -26,4 +26,4 @@ "is_default": false } } -} \ No newline at end of file +} diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/handle_cors.py b/lifewatch_batch_platform/terraform/backend_lambdas/handle_cors.py index f1350a1..0768d68 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/handle_cors.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/handle_cors.py @@ -9,7 +9,9 @@ } -def response(status_code: int, body: Any, headers: Dict[str, str] | None = None) -> Dict[str, Any]: +def response( + status_code: int, body: Any, headers: Dict[str, str] | None = None +) -> Dict[str, Any]: payload = body if isinstance(body, str) else json.dumps(body) return { "statusCode": status_code, diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/history_list.py b/lifewatch_batch_platform/terraform/backend_lambdas/history_list.py index 31fe53e..df4ea81 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/history_list.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/history_list.py @@ -8,18 +8,19 @@ batch = boto3.client("batch") BUCKET = os.environ["BUCKET"] + def lambda_handler(event, context): try: - paginator = s3.get_paginator('list_objects_v2') + paginator = s3.get_paginator("list_objects_v2") jobs_history = [] job_map = {} - + # Fetch metadata from S3 for page in paginator.paginate(Bucket=BUCKET, Prefix="jobs/", Delimiter="/"): - for prefix in page.get('CommonPrefixes', []): - job_folder = prefix.get('Prefix') - job_id = job_folder.split('/')[1] - + for prefix in page.get("CommonPrefixes", []): + job_folder = prefix.get("Prefix") + job_id = job_folder.split("/")[1] + # Base object mirrors JobHistoryItem interface for frontend ts_item = { "jobId": job_id, @@ -30,50 +31,58 @@ def lambda_handler(event, context): "params": {}, "status": "UNKNOWN", "logs": "", # Fetch separately in frontend - "artifactUrl": None, # Fetch separately in frontend + "artifactUrl": None, # Fetch separately in frontend "s3Uri": f"s3://{BUCKET}/{job_folder}", "info": None, "error": None, - "lastCheckedAt": datetime.now(timezone.utc).isoformat() + "lastCheckedAt": datetime.now(timezone.utc).isoformat(), } - + try: - meta_obj = s3.get_object(Bucket=BUCKET, Key=f"{job_folder}meta.json") - + meta_obj = s3.get_object( + Bucket=BUCKET, Key=f"{job_folder}meta.json" + ) + # Extract timestamp from S3 metadata - ts_item["submittedAt"] = meta_obj['LastModified'].isoformat() - meta_content = json.loads(meta_obj['Body'].read().decode('utf-8')) - ts_item["executionProfile"] = meta_content.get("execution_profile", "unknown") - ts_item["notebookName"] = meta_content.get("notebook_name", "notebook.ipynb") - ts_item["environmentName"] = meta_content.get("environment_name", "environment.yaml") + ts_item["submittedAt"] = meta_obj["LastModified"].isoformat() + meta_content = json.loads(meta_obj["Body"].read().decode("utf-8")) + ts_item["executionProfile"] = meta_content.get( + "execution_profile", "unknown" + ) + ts_item["notebookName"] = meta_content.get( + "notebook_name", "notebook.ipynb" + ) + ts_item["environmentName"] = meta_content.get( + "environment_name", "environment.yaml" + ) ts_item["params"] = meta_content.get("params", {}) - + batch_id = meta_content.get("batch_job_id") if batch_id: job_map[batch_id] = ts_item - + except s3.exceptions.NoSuchKey: - pass # Skip if the JSON is missing - + pass # Skip if the JSON is missing + jobs_history.append(ts_item) - + # Fetch Status from Batch batch_ids = list(job_map.keys()) if batch_ids: # describe_jobs has a limit of 100 jobs for i in range(0, len(batch_ids), 100): - chunk = batch_ids[i:i + 100] + chunk = batch_ids[i : i + 100] batch_response = batch.describe_jobs(jobs=chunk) - - for job in batch_response.get('jobs', []): - b_id = job['jobId'] - status = job['status'] - reason = job.get('statusReason', '') + + for job in batch_response.get("jobs", []): + b_id = job["jobId"] + status = job["status"] + reason = job.get("statusReason", "") # Update the object in place mapped_item = job_map[b_id] mapped_item["status"] = status - + if status == "FAILED": mapped_item["error"] = reason elif reason: @@ -81,12 +90,13 @@ def lambda_handler(event, context): # Sort Chronologically jobs_history.sort( - key=lambda x: x.get('submittedAt') or "1970-01-01T00:00:00+00:00", - reverse=True + key=lambda x: x.get("submittedAt") or "1970-01-01T00:00:00+00:00", + reverse=True, ) return cors_response(200, {"jobs": jobs_history}) except Exception as e: import traceback - return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) \ No newline at end of file + + return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/lambda_function.py b/lifewatch_batch_platform/terraform/backend_lambdas/lambda_function.py index 240c5cd..92317d9 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/lambda_function.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/lambda_function.py @@ -15,6 +15,7 @@ JOB_PROFILES_CONFIG = json.loads(os.environ.get("JOB_PROFILES_CONFIG", "{}")) + def normalize_execution_profile(raw_profile): normalized_key = (raw_profile or "standard").strip().lower() for profile_key, profile_data in JOB_PROFILES_CONFIG.items(): @@ -26,29 +27,39 @@ def normalize_execution_profile(raw_profile): # Default to 'standard' return "standard" + def resolve_batch_target(execution_profile): if execution_profile not in JOB_PROFILES_CONFIG: raise ValueError(f"Unsupported execution profile: {execution_profile}") - + config = JOB_PROFILES_CONFIG[execution_profile] return config["queue"], config["definition"] + def lambda_handler(event, context): try: # Initialize Job Context job_id = str(uuid.uuid4()) s3_prefix = f"jobs/{job_id}/" - + # Extract and decode the request body (API Gateway handling) - content_type = event.get("headers", {}).get("content-type") or event.get("headers", {}).get("Content-Type", "") + content_type = event.get("headers", {}).get("content-type") or event.get( + "headers", {} + ).get("Content-Type", "") if not content_type.startswith("multipart/form-data"): return cors_response(400, {"error": "Request must be multipart/form-data"}) raw_body = event.get("body", "") - body_bytes = base64.b64decode(raw_body) if event.get("isBase64Encoded") else raw_body.encode("utf-8") + body_bytes = ( + base64.b64decode(raw_body) + if event.get("isBase64Encoded") + else raw_body.encode("utf-8") + ) # Use Python's built-in email parser to parse multipart data (no external libraries needed) - headers_and_body = f"Content-Type: {content_type}\r\n\r\n".encode("utf-8") + body_bytes + headers_and_body = ( + f"Content-Type: {content_type}\r\n\r\n".encode("utf-8") + body_bytes + ) msg = BytesParser(policy=default).parsebytes(headers_and_body) notebook_content = None @@ -74,46 +85,60 @@ def lambda_handler(event, context): # If no filename, treat it as a parameter key-value pair params[field_name] = payload.decode("utf-8").strip() - raw_execution_profile = params.get("execution_profile") or params.get("compute_profile") + raw_execution_profile = params.get("execution_profile") or params.get( + "compute_profile" + ) execution_profile = normalize_execution_profile(raw_execution_profile) if not execution_profile: - return cors_response(400, { - "error": "Invalid execution_profile. Allowed values: standard, ec2_200gb" - }) - - selected_job_queue, selected_job_definition = resolve_batch_target(execution_profile) + return cors_response( + 400, + { + "error": "Invalid execution_profile. Allowed values: standard, ec2_200gb" + }, + ) + + selected_job_queue, selected_job_definition = resolve_batch_target( + execution_profile + ) if not notebook_content: return cors_response(400, {"error": "Missing mandatory 'notebook' file."}) notebook_json = json.loads(notebook_content.decode("utf-8")) - + # Auto-detect language to choose the correct assignment operator - lang = notebook_json.get("metadata", {}).get("language_info", {}).get("name", "r").lower() + lang = ( + notebook_json.get("metadata", {}) + .get("language_info", {}) + .get("name", "r") + .lower() + ) assign_op = "=" if lang == "python" else "<-" # Format the parameters dynamically formatted_params = {} for key, val in params.items(): if key.startswith("param_"): - if val.lstrip('-').replace(".", "", 1).isdigit(): - formatted_params[key] = f'{key} {assign_op} {val}\n' + if val.lstrip("-").replace(".", "", 1).isdigit(): + formatted_params[key] = f"{key} {assign_op} {val}\n" else: formatted_params[key] = f'{key} {assign_op} "{val}"\n' # Inject LifeWatch configuration overrides - formatted_params.update({ - "conf_temporary_data_directory": f'conf_temporary_data_directory {assign_op} "./outputs"\n', - "conf_virtual_lab_biotisan_euromarec": f'conf_virtual_lab_biotisan_euromarec {assign_op} "vl-biotisan-euromarec"\n', - "conf_naavre_public": f'conf_naavre_public {assign_op} "naa-vre-public"\n', - "conf_naavre_user_data": f'conf_naavre_user_data {assign_op} ""\n', - "conf_cloud_storage_path": f'conf_cloud_storage_path {assign_op} "."\n' - }) + formatted_params.update( + { + "conf_temporary_data_directory": f'conf_temporary_data_directory {assign_op} "./outputs"\n', + "conf_virtual_lab_biotisan_euromarec": f'conf_virtual_lab_biotisan_euromarec {assign_op} "vl-biotisan-euromarec"\n', + "conf_naavre_public": f'conf_naavre_public {assign_op} "naa-vre-public"\n', + "conf_naavre_user_data": f'conf_naavre_user_data {assign_op} ""\n', + "conf_cloud_storage_path": f'conf_cloud_storage_path {assign_op} "."\n', + } + ) # Dictionary to hold the final merged parameters final_params = {} - - # Regex to find default parameter assignments + + # Regex to find default parameter assignments param_regex = re.compile(r"^(param_[a-zA-Z0-9_]+)\s*(?:<-|=)\s*(.*)") # Loop through all cells and substitute the existing lines @@ -121,15 +146,14 @@ def lambda_handler(event, context): if cell.get("cell_type") == "code": new_source = [] for line in cell.get("source", []): - # Extract defaults match = param_regex.match(line.strip()) if match: p_key = match.group(1) p_val_raw = match.group(2) - - p_val_clean = p_val_raw.split('#')[0].strip().strip("'\"") - + + p_val_clean = p_val_raw.split("#")[0].strip().strip("'\"") + # Store it as a default if p_key not in final_params: final_params[p_key] = p_val_clean @@ -140,12 +164,12 @@ def lambda_handler(event, context): if re.match(rf"^{re.escape(param_key)}\s*(<-|=)", line): new_source.append(param_line) replaced = True - break # Move to the next line in the cell once replaced - + break # Move to the next line in the cell once replaced + # If it's not a parameter or config line, keep the original line if not replaced: new_source.append(line) - + # Update the cell's source code cell["source"] = new_source @@ -157,13 +181,23 @@ def lambda_handler(event, context): final_params[k] = v # Upload Artifacts to S3 - s3.put_object(Bucket=BUCKET, Key=f"{s3_prefix}notebook.ipynb", Body=updated_notebook_bytes) - + s3.put_object( + Bucket=BUCKET, Key=f"{s3_prefix}notebook.ipynb", Body=updated_notebook_bytes + ) + if environment_content: - s3.put_object(Bucket=BUCKET, Key=f"{s3_prefix}environment.yaml", Body=environment_content) - + s3.put_object( + Bucket=BUCKET, + Key=f"{s3_prefix}environment.yaml", + Body=environment_content, + ) + for f in files_to_upload: - s3.put_object(Bucket=BUCKET, Key=f"{s3_prefix}inputs/{f['filename']}", Body=f["content"]) + s3.put_object( + Bucket=BUCKET, + Key=f"{s3_prefix}inputs/{f['filename']}", + Body=f["content"], + ) # Submit the Batch Job response = batch.submit_job( @@ -174,9 +208,9 @@ def lambda_handler(event, context): "environment": [ # Pass the S3 location so the worker knows where to pull from {"name": "JOB_ID", "value": job_id}, - {"name": "S3_JOB_PREFIX", "value": f"s3://{BUCKET}/{s3_prefix}"} + {"name": "S3_JOB_PREFIX", "value": f"s3://{BUCKET}/{s3_prefix}"}, ] - } + }, ) # Tracking file in S3 links the custom job_id to the AWS Batch ID @@ -184,22 +218,33 @@ def lambda_handler(event, context): meta_payload = { "batch_job_id": response["jobId"], "execution_profile": execution_profile, - "notebook_name": next((f["filename"] for f in files_to_upload if f["filename"].endswith(".ipynb")), "notebook.ipynb"), + "notebook_name": next( + ( + f["filename"] + for f in files_to_upload + if f["filename"].endswith(".ipynb") + ), + "notebook.ipynb", + ), "environment_name": "environment.yaml" if environment_content else "none", - "params": final_params + "params": final_params, } s3.put_object( Bucket=BUCKET, Key=f"{s3_prefix}meta.json", - Body=json.dumps(meta_payload).encode("utf-8") + Body=json.dumps(meta_payload).encode("utf-8"), ) - return cors_response(200, { - "message": "Job successfully mapped and submitted", - "job_id": job_id, - "execution_profile": execution_profile - }) + return cors_response( + 200, + { + "message": "Job successfully mapped and submitted", + "job_id": job_id, + "execution_profile": execution_profile, + }, + ) except Exception as e: import traceback - return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) \ No newline at end of file + + return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/logs.py b/lifewatch_batch_platform/terraform/backend_lambdas/logs.py index 6b898cb..a6cca86 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/logs.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/logs.py @@ -10,6 +10,7 @@ LOG_GROUP = "/aws/batch/job" BUCKET = os.environ["BUCKET"] + def lambda_handler(event, context): """ Returns CloudWatch logs for a Batch job. @@ -26,7 +27,9 @@ def lambda_handler(event, context): meta = json.loads(meta_obj["Body"].read().decode("utf-8")) batch_job_id = meta["batch_job_id"] except s3.exceptions.NoSuchKey: - return cors_response(404, {"error": f"Job metadata not found for job_id: {job_id}"}) + return cors_response( + 404, {"error": f"Job metadata not found for job_id: {job_id}"} + ) # Get the job description response = batch_client.describe_jobs(jobs=[batch_job_id]) @@ -42,9 +45,7 @@ def lambda_handler(event, context): # Fetch log events log_events = logs_client.get_log_events( - logGroupName=LOG_GROUP, - logStreamName=log_stream, - startFromHead=True + logGroupName=LOG_GROUP, logStreamName=log_stream, startFromHead=True ) messages = [e["message"] for e in log_events.get("events", [])] @@ -53,4 +54,5 @@ def lambda_handler(event, context): except Exception as e: import traceback + return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/results.py b/lifewatch_batch_platform/terraform/backend_lambdas/results.py index 70bfe63..56c50f7 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/results.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/results.py @@ -1,5 +1,4 @@ import os -import json import boto3 from botocore.exceptions import ClientError from handle_cors import response as cors_response @@ -7,6 +6,7 @@ s3 = boto3.client("s3") BUCKET = os.environ["BUCKET"] + def lambda_handler(event, context): """ Returns a pre-signed URL to download the zipped output of a Batch job. @@ -23,7 +23,7 @@ def lambda_handler(event, context): failed_key = f"{base_prefix}failed_outputs.zip" file_key = None - + # Try to verify the successful outputs.zip exists try: # Using head_object instead of get_object saves RAM and execution time @@ -31,17 +31,19 @@ def lambda_handler(event, context): file_key = success_key except ClientError as e: # head_object returns a 404 string instead of 'NoSuchKey' - if e.response['Error']['Code'] == '404': - + if e.response["Error"]["Code"] == "404": # If it doesn't exist, check if there is a failed_outputs.zip try: s3.head_object(Bucket=BUCKET, Key=failed_key) file_key = failed_key except ClientError as e_fallback: - if e_fallback.response['Error']['Code'] == '404': - return cors_response(404, { - "error": f"No outputs found for job {job_id}. The job may still be running, or it crashed before generating outputs." - }) + if e_fallback.response["Error"]["Code"] == "404": + return cors_response( + 404, + { + "error": f"No outputs found for job {job_id}. The job may still be running, or it crashed before generating outputs." + }, + ) else: raise e_fallback else: @@ -51,20 +53,19 @@ def lambda_handler(event, context): # Generate a secure URL valid for 1 hour (3600 seconds) presigned_url = s3.generate_presigned_url( - 'get_object', - Params={'Bucket': BUCKET, 'Key': file_key}, - ExpiresIn=3600 + "get_object", Params={"Bucket": BUCKET, "Key": file_key}, ExpiresIn=3600 ) - return cors_response(200, { - "job_id": job_id, - "status": "success" if file_key == success_key else "partial_failure", - "download_url": presigned_url - }) + return cors_response( + 200, + { + "job_id": job_id, + "status": "success" if file_key == success_key else "partial_failure", + "download_url": presigned_url, + }, + ) except Exception as e: import traceback - return cors_response(500, { - "error": str(e), - "trace": traceback.format_exc() - }) + + return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/status.py b/lifewatch_batch_platform/terraform/backend_lambdas/status.py index 0194070..5228de9 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/status.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/status.py @@ -7,6 +7,7 @@ s3 = boto3.client("s3") BUCKET = os.environ["BUCKET"] + def lambda_handler(event, context): """ Returns the status of a job. @@ -24,7 +25,9 @@ def lambda_handler(event, context): meta = json.loads(meta_obj["Body"].read().decode("utf-8")) batch_job_id = meta["batch_job_id"] except s3.exceptions.NoSuchKey: - return cors_response(404, {"error": f"Job metadata not found for job_id: {job_id}"}) + return cors_response( + 404, {"error": f"Job metadata not found for job_id: {job_id}"} + ) # Describe the job using the Batch ID response = batch.describe_jobs(jobs=[batch_job_id]) @@ -33,15 +36,19 @@ def lambda_handler(event, context): job = response["jobs"][0] - return cors_response(200, { - "job_id": job_id, - "job_name": job.get("jobName"), - "status": job.get("status"), - "createdAt": job.get("createdAt"), - "startedAt": job.get("startedAt"), - "stoppedAt": job.get("stoppedAt"), - }) + return cors_response( + 200, + { + "job_id": job_id, + "job_name": job.get("jobName"), + "status": job.get("status"), + "createdAt": job.get("createdAt"), + "startedAt": job.get("startedAt"), + "stoppedAt": job.get("stoppedAt"), + }, + ) except Exception as e: import traceback + return cors_response(500, {"error": str(e), "trace": traceback.format_exc()}) diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/tests/bootstrap_aws_stubs.py b/lifewatch_batch_platform/terraform/backend_lambdas/tests/bootstrap_aws_stubs.py new file mode 100644 index 0000000..06ac28c --- /dev/null +++ b/lifewatch_batch_platform/terraform/backend_lambdas/tests/bootstrap_aws_stubs.py @@ -0,0 +1,30 @@ +import os +import sys +from pathlib import Path +from types import ModuleType +from unittest.mock import Mock + + +class ClientError(Exception): + def __init__(self, error_response, operation_name): + super().__init__(str(error_response)) + self.response = error_response + self.operation_name = operation_name + + +os.environ.setdefault("BUCKET", "test-bucket") + +BASE_DIR = Path(__file__).resolve().parents[1] +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +fake_boto3 = ModuleType("boto3") +fake_boto3.client = lambda _service_name: Mock() +sys.modules["boto3"] = fake_boto3 + +fake_botocore_exceptions = ModuleType("botocore.exceptions") +fake_botocore_exceptions.ClientError = ClientError +fake_botocore = ModuleType("botocore") +fake_botocore.exceptions = fake_botocore_exceptions +sys.modules["botocore"] = fake_botocore +sys.modules["botocore.exceptions"] = fake_botocore_exceptions diff --git a/lifewatch_batch_platform/terraform/backend_lambdas/tests/test_lambdas.py b/lifewatch_batch_platform/terraform/backend_lambdas/tests/test_lambdas.py index 9e3e91f..6ed7ff7 100644 --- a/lifewatch_batch_platform/terraform/backend_lambdas/tests/test_lambdas.py +++ b/lifewatch_batch_platform/terraform/backend_lambdas/tests/test_lambdas.py @@ -1,42 +1,14 @@ import json -import os -import sys import unittest from datetime import datetime, timezone from email.generator import BytesGenerator from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from io import BytesIO -from pathlib import Path -from types import ModuleType from types import SimpleNamespace from unittest.mock import Mock, patch - -class ClientError(Exception): - def __init__(self, error_response, operation_name): - super().__init__(str(error_response)) - self.response = error_response - self.operation_name = operation_name - - -fake_boto3 = ModuleType("boto3") -fake_boto3.client = lambda _service_name: Mock() -sys.modules["boto3"] = fake_boto3 - -fake_botocore_exceptions = ModuleType("botocore.exceptions") -fake_botocore_exceptions.ClientError = ClientError -fake_botocore = ModuleType("botocore") -fake_botocore.exceptions = fake_botocore_exceptions -sys.modules["botocore"] = fake_botocore -sys.modules["botocore.exceptions"] = fake_botocore_exceptions - -BASE_DIR = Path(__file__).resolve().parents[1] -if str(BASE_DIR) not in sys.path: - sys.path.insert(0, str(BASE_DIR)) - -os.environ.setdefault("BUCKET", "test-bucket") - +from bootstrap_aws_stubs import ClientError import history_list import lambda_function import logs @@ -75,7 +47,9 @@ class TestSubmitLambda(unittest.TestCase): def test_lambda_handler_rejects_non_multipart(self): response = lambda_function.lambda_handler({"headers": {}, "body": "{}"}, None) self.assertEqual(response["statusCode"], 400) - self.assertEqual(parse_body(response)["error"], "Request must be multipart/form-data") + self.assertEqual( + parse_body(response)["error"], "Request must be multipart/form-data" + ) def test_lambda_handler_submits_job_for_minimal_valid_payload(self): lambda_function.JOB_PROFILES_CONFIG = { @@ -121,7 +95,11 @@ def test_lambda_handler_submits_job_for_minimal_valid_payload(self): batch_mock = Mock() batch_mock.submit_job.return_value = {"jobId": "batch-123"} - with patch.object(lambda_function, "s3", s3_mock), patch.object(lambda_function, "batch", batch_mock), patch.object(lambda_function.uuid, "uuid4", return_value="job-123"): + with ( + patch.object(lambda_function, "s3", s3_mock), + patch.object(lambda_function, "batch", batch_mock), + patch.object(lambda_function.uuid, "uuid4", return_value="job-123"), + ): response = lambda_function.lambda_handler( { "headers": {"content-type": content_type}, @@ -169,7 +147,9 @@ def test_lambda_handler_rejects_missing_notebook_part(self): ) self.assertEqual(response["statusCode"], 400) - self.assertEqual(parse_body(response)["error"], "Missing mandatory 'notebook' file.") + self.assertEqual( + parse_body(response)["error"], "Missing mandatory 'notebook' file." + ) def test_lambda_handler_uploads_environment_and_input_files(self): lambda_function.JOB_PROFILES_CONFIG = { @@ -215,7 +195,11 @@ def test_lambda_handler_uploads_environment_and_input_files(self): batch_mock = Mock() batch_mock.submit_job.return_value = {"jobId": "batch-123"} - with patch.object(lambda_function, "s3", s3_mock), patch.object(lambda_function, "batch", batch_mock), patch.object(lambda_function.uuid, "uuid4", return_value="job-456"): + with ( + patch.object(lambda_function, "s3", s3_mock), + patch.object(lambda_function, "batch", batch_mock), + patch.object(lambda_function.uuid, "uuid4", return_value="job-456"), + ): response = lambda_function.lambda_handler( { "headers": {"content-type": content_type}, @@ -228,7 +212,9 @@ def test_lambda_handler_uploads_environment_and_input_files(self): self.assertEqual(response["statusCode"], 200) self.assertEqual(s3_mock.put_object.call_count, 4) - uploaded_keys = [call.kwargs["Key"] for call in s3_mock.put_object.call_args_list] + uploaded_keys = [ + call.kwargs["Key"] for call in s3_mock.put_object.call_args_list + ] self.assertIn("jobs/job-456/environment.yaml", uploaded_keys) self.assertIn("jobs/job-456/inputs/input.csv", uploaded_keys) @@ -258,8 +244,13 @@ def test_successful_status_lookup(self): ] } - with patch.object(status, "s3", s3_mock), patch.object(status, "batch", batch_mock): - response = status.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + with ( + patch.object(status, "s3", s3_mock), + patch.object(status, "batch", batch_mock), + ): + response = status.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) payload = parse_body(response) self.assertEqual(response["statusCode"], 200) @@ -275,8 +266,13 @@ def test_returns_404_when_batch_job_missing(self): batch_mock = Mock() batch_mock.describe_jobs.return_value = {"jobs": []} - with patch.object(status, "s3", s3_mock), patch.object(status, "batch", batch_mock): - response = status.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + with ( + patch.object(status, "s3", s3_mock), + patch.object(status, "batch", batch_mock), + ): + response = status.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) self.assertEqual(response["statusCode"], 404) self.assertEqual(parse_body(response)["error"], "Batch Job not found in AWS") @@ -297,8 +293,13 @@ def test_returns_404_when_batch_job_missing(self): batch_mock = Mock() batch_mock.describe_jobs.return_value = {"jobs": []} - with patch.object(logs, "s3", s3_mock), patch.object(logs, "batch_client", batch_mock): - response = logs.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + with ( + patch.object(logs, "s3", s3_mock), + patch.object(logs, "batch_client", batch_mock), + ): + response = logs.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) self.assertEqual(response["statusCode"], 404) self.assertEqual(parse_body(response)["error"], "Batch Job not found in AWS") @@ -313,8 +314,13 @@ def test_returns_404_when_log_stream_missing(self): batch_mock = Mock() batch_mock.describe_jobs.return_value = {"jobs": [{"container": {}}]} - with patch.object(logs, "s3", s3_mock), patch.object(logs, "batch_client", batch_mock): - response = logs.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + with ( + patch.object(logs, "s3", s3_mock), + patch.object(logs, "batch_client", batch_mock), + ): + response = logs.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) self.assertEqual(response["statusCode"], 404) @@ -335,8 +341,14 @@ def test_returns_log_messages(self): "events": [{"message": "line-1"}, {"message": "line-2"}] } - with patch.object(logs, "s3", s3_mock), patch.object(logs, "batch_client", batch_mock), patch.object(logs, "logs_client", logs_client_mock): - response = logs.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + with ( + patch.object(logs, "s3", s3_mock), + patch.object(logs, "batch_client", batch_mock), + patch.object(logs, "logs_client", logs_client_mock), + ): + response = logs.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) payload = parse_body(response) self.assertEqual(response["statusCode"], 200) @@ -353,7 +365,9 @@ def test_returns_success_presigned_url_when_outputs_zip_exists(self): s3_mock.generate_presigned_url.return_value = "https://example/download" with patch.object(results, "s3", s3_mock): - response = results.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + response = results.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) payload = parse_body(response) self.assertEqual(response["statusCode"], 200) @@ -370,7 +384,9 @@ def test_falls_back_to_failed_outputs(self): s3_mock.generate_presigned_url.return_value = "https://example/download-failed" with patch.object(results, "s3", s3_mock): - response = results.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + response = results.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) payload = parse_body(response) self.assertEqual(response["statusCode"], 200) @@ -386,7 +402,9 @@ def test_returns_404_when_no_outputs_exist(self): s3_mock.head_object.side_effect = [not_found_error, not_found_error] with patch.object(results, "s3", s3_mock): - response = results.lambda_handler({"pathParameters": {"job_id": "job-1"}}, None) + response = results.lambda_handler( + {"pathParameters": {"job_id": "job-1"}}, None + ) self.assertEqual(response["statusCode"], 404) self.assertIn("No outputs found for job job-1", parse_body(response)["error"]) @@ -395,11 +413,15 @@ def test_returns_404_when_no_outputs_exist(self): class TestHistoryListLambda(unittest.TestCase): def test_builds_history_items_and_maps_batch_status(self): body_mock = Mock() - body_mock.read.return_value = b'{"batch_job_id": "batch-abc", "execution_profile": "standard"}' + body_mock.read.return_value = ( + b'{"batch_job_id": "batch-abc", "execution_profile": "standard"}' + ) s3_mock = Mock() paginator_mock = Mock() - paginator_mock.paginate.return_value = [{"CommonPrefixes": [{"Prefix": "jobs/job-1/"}]}] + paginator_mock.paginate.return_value = [ + {"CommonPrefixes": [{"Prefix": "jobs/job-1/"}]} + ] s3_mock.get_paginator.return_value = paginator_mock s3_mock.get_object.return_value = { "Body": body_mock, @@ -420,7 +442,10 @@ def test_builds_history_items_and_maps_batch_status(self): ] } - with patch.object(history_list, "s3", s3_mock), patch.object(history_list, "batch", batch_mock): + with ( + patch.object(history_list, "s3", s3_mock), + patch.object(history_list, "batch", batch_mock), + ): response = history_list.lambda_handler({}, None) payload = parse_body(response) @@ -431,11 +456,15 @@ def test_builds_history_items_and_maps_batch_status(self): def test_maps_non_failed_status_reason_to_info(self): body_mock = Mock() - body_mock.read.return_value = b'{"batch_job_id": "batch-abc", "execution_profile": "standard"}' + body_mock.read.return_value = ( + b'{"batch_job_id": "batch-abc", "execution_profile": "standard"}' + ) s3_mock = Mock() paginator_mock = Mock() - paginator_mock.paginate.return_value = [{"CommonPrefixes": [{"Prefix": "jobs/job-2/"}]}] + paginator_mock.paginate.return_value = [ + {"CommonPrefixes": [{"Prefix": "jobs/job-2/"}]} + ] s3_mock.get_paginator.return_value = paginator_mock s3_mock.get_object.return_value = { "Body": body_mock, @@ -456,7 +485,10 @@ def test_maps_non_failed_status_reason_to_info(self): ] } - with patch.object(history_list, "s3", s3_mock), patch.object(history_list, "batch", batch_mock): + with ( + patch.object(history_list, "s3", s3_mock), + patch.object(history_list, "batch", batch_mock), + ): response = history_list.lambda_handler({}, None) payload = parse_body(response) diff --git a/lifewatch_batch_platform/terraform/client_requests/e2e_notebook_run.py b/lifewatch_batch_platform/terraform/client_requests/e2e_notebook_run.py index a87edc7..b0bdae9 100644 --- a/lifewatch_batch_platform/terraform/client_requests/e2e_notebook_run.py +++ b/lifewatch_batch_platform/terraform/client_requests/e2e_notebook_run.py @@ -17,19 +17,36 @@ def parse_args(): parser = argparse.ArgumentParser( description="Run an end-to-end AWS Batch notebook execution test against the deployed API." ) - parser.add_argument("--api-base-url", required=True, help="API base URL, e.g. https://.../dev") - parser.add_argument("--api-key", required=True, help="API key used in x-api-key header") - parser.add_argument("--notebook-path", required=True, help="Path to notebook .ipynb") - parser.add_argument("--data-file-path", required=True, help="Path to main data input file") - parser.add_argument("--environment-path", required=True, help="Path to environment.yaml") + parser.add_argument( + "--api-base-url", required=True, help="API base URL, e.g. https://.../dev" + ) + parser.add_argument( + "--api-key", required=True, help="API key used in x-api-key header" + ) + parser.add_argument( + "--notebook-path", required=True, help="Path to notebook .ipynb" + ) + parser.add_argument( + "--data-file-path", required=True, help="Path to main data input file" + ) + parser.add_argument( + "--environment-path", required=True, help="Path to environment.yaml" + ) parser.add_argument( "--execution-profile", default="ec2_200gb", choices=["standard", "ec2_200gb"], help="Batch execution profile", ) - parser.add_argument("--poll-interval-seconds", type=int, default=20, help="Polling interval in seconds") - parser.add_argument("--timeout-seconds", type=int, default=3600, help="Overall timeout in seconds") + parser.add_argument( + "--poll-interval-seconds", + type=int, + default=20, + help="Polling interval in seconds", + ) + parser.add_argument( + "--timeout-seconds", type=int, default=3600, help="Overall timeout in seconds" + ) parser.add_argument( "--output-dir", default="lifewatch_batch_platform/terraform/environments/dev/e2e_outputs", @@ -69,7 +86,9 @@ def request_json( try: payload = response.json() except ValueError as exc: - raise RuntimeError(f"Non-JSON response from {url} ({response.status_code}): {body_text}") from exc + raise RuntimeError( + f"Non-JSON response from {url} ({response.status_code}): {body_text}" + ) from exc if response.status_code < 400: return payload @@ -80,7 +99,9 @@ def request_json( f"Transient API error from {url} ({response.status_code}) on attempt {attempt}/{retries}; retrying in {retry_delay_seconds}s ..." ) time.sleep(retry_delay_seconds) - last_error = RuntimeError(f"API error from {url} ({response.status_code}): {payload}") + last_error = RuntimeError( + f"API error from {url} ({response.status_code}): {payload}" + ) continue raise RuntimeError(f"API error from {url} ({response.status_code}): {payload}") @@ -92,7 +113,9 @@ def request_json( def submit_job(args, headers): submit_url = f"{args.api_base_url.rstrip('/')}/batch/jobs" - print(f"Submitting notebook job to {submit_url} using profile={args.execution_profile} ...") + print( + f"Submitting notebook job to {submit_url} using profile={args.execution_profile} ..." + ) with ( open(args.notebook_path, "rb") as notebook_file, @@ -100,15 +123,26 @@ def submit_job(args, headers): open(args.environment_path, "rb") as env_file, ): files = { - "notebook": (os.path.basename(args.notebook_path), notebook_file.read(), "application/x-ipynb+json"), + "notebook": ( + os.path.basename(args.notebook_path), + notebook_file.read(), + "application/x-ipynb+json", + ), "upload_01": ( os.path.basename(args.data_file_path), data_file.read(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ), - "environment": (os.path.basename(args.environment_path), env_file.read(), "application/x-yaml"), + "environment": ( + os.path.basename(args.environment_path), + env_file.read(), + "application/x-yaml", + ), # Notebook parameters used in the current demo flow - "param_01_input_data_filename": (None, os.path.basename(args.data_file_path)), + "param_01_input_data_filename": ( + None, + os.path.basename(args.data_file_path), + ), "param_02_input_data_sheet": (None, "BIRDS"), "param_03_input_metadata_sheet": (None, "METADATA"), "param_04_output_samples_ecological_parameters": (None, "false"), @@ -135,7 +169,9 @@ def submit_job(args, headers): raise RuntimeError(f"Submit response missing job_id: {payload}") if batch_job_id: - print(f"Submitted OK. notebook_job_id={notebook_job_id} batch_job_id={batch_job_id}") + print( + f"Submitted OK. notebook_job_id={notebook_job_id} batch_job_id={batch_job_id}" + ) else: print(f"Submitted OK. notebook_job_id={notebook_job_id}") return payload, notebook_job_id, batch_job_id @@ -166,7 +202,9 @@ def _fetch_with_candidate_ids(api_base_url, headers, endpoint_suffix, candidate_ raise RuntimeError("No candidate IDs available for endpoint lookup.") -def poll_batch_status(api_base_url, headers, candidate_ids, poll_interval, timeout_seconds): +def poll_batch_status( + api_base_url, headers, candidate_ids, poll_interval, timeout_seconds +): active_status_id = None deadline = time.time() + timeout_seconds last_status = None @@ -205,7 +243,9 @@ def poll_batch_status(api_base_url, headers, candidate_ids, poll_interval, timeo def get_logs(api_base_url, headers, candidate_ids): - _, logs_payload = _fetch_with_candidate_ids(api_base_url, headers, "/logs", candidate_ids) + _, logs_payload = _fetch_with_candidate_ids( + api_base_url, headers, "/logs", candidate_ids + ) raw_logs = logs_payload.get("logs", "") if isinstance(raw_logs, list): return "\n".join(str(line) for line in raw_logs) @@ -286,7 +326,9 @@ def main(): job_output_dir = base_output / notebook_job_id job_output_dir.mkdir(parents=True, exist_ok=True) - logs_candidate_ids = [id_ for id_ in [status_lookup_id, notebook_job_id, batch_job_id] if id_] + logs_candidate_ids = [ + id_ for id_ in [status_lookup_id, notebook_job_id, batch_job_id] if id_ + ] logs_text = get_logs(args.api_base_url, headers, logs_candidate_ids) (job_output_dir / "batch_logs.txt").write_text(logs_text, encoding="utf-8") print(f"Saved logs to {job_output_dir / 'batch_logs.txt'}") @@ -310,7 +352,9 @@ def main(): "results_count": len(results_payload.get("results", [])), "written_files": written_files, } - (job_output_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") + (job_output_dir / "summary.json").write_text( + json.dumps(summary, indent=2), encoding="utf-8" + ) print(f"E2E SUCCESS. Summary saved to {job_output_dir / 'summary.json'}") return 0 diff --git a/lifewatch_batch_platform/terraform/client_requests/fetch_api_key.py b/lifewatch_batch_platform/terraform/client_requests/fetch_api_key.py index febdf8d..4b95eed 100644 --- a/lifewatch_batch_platform/terraform/client_requests/fetch_api_key.py +++ b/lifewatch_batch_platform/terraform/client_requests/fetch_api_key.py @@ -1,6 +1,7 @@ import os import subprocess + def fetch_api_key() -> str: env_key = os.getenv("LIFEWATCH_API_KEY", "").strip() if env_key: @@ -16,4 +17,6 @@ def fetch_api_key() -> str: return key except Exception: pass - raise RuntimeError("Unable to resolve API key. Set LIFEWATCH_API_KEY or run terraform output first.") \ No newline at end of file + raise RuntimeError( + "Unable to resolve API key. Set LIFEWATCH_API_KEY or run terraform output first." + ) diff --git a/lifewatch_batch_platform/terraform/client_requests/fetch_api_url.py b/lifewatch_batch_platform/terraform/client_requests/fetch_api_url.py index 14d8370..af6416d 100644 --- a/lifewatch_batch_platform/terraform/client_requests/fetch_api_url.py +++ b/lifewatch_batch_platform/terraform/client_requests/fetch_api_url.py @@ -1,19 +1,22 @@ -import os -import subprocess - -def fetch_api_url() -> str: - env_url = os.getenv("LIFEWATCH_API_URL", "").strip() - if env_url: - return env_url - try: - dev_dir = os.path.join(os.path.dirname(__file__), "..", "environments", "dev") - url = subprocess.check_output( - ["terraform", "output", "-raw", "api_gateway_url"], - cwd=os.path.abspath(dev_dir), - text=True, - ).strip() - if url: - return url - except Exception: - pass - raise RuntimeError("Unable to resolve API URL. Set LIFEWATCH_API_URL or run terraform output first.") \ No newline at end of file +import os +import subprocess + + +def fetch_api_url() -> str: + env_url = os.getenv("LIFEWATCH_API_URL", "").strip() + if env_url: + return env_url + try: + dev_dir = os.path.join(os.path.dirname(__file__), "..", "environments", "dev") + url = subprocess.check_output( + ["terraform", "output", "-raw", "api_gateway_url"], + cwd=os.path.abspath(dev_dir), + text=True, + ).strip() + if url: + return url + except Exception: + pass + raise RuntimeError( + "Unable to resolve API URL. Set LIFEWATCH_API_URL or run terraform output first." + ) diff --git a/lifewatch_batch_platform/terraform/client_requests/get_history_list.py b/lifewatch_batch_platform/terraform/client_requests/get_history_list.py index cf8a649..be85847 100644 --- a/lifewatch_batch_platform/terraform/client_requests/get_history_list.py +++ b/lifewatch_batch_platform/terraform/client_requests/get_history_list.py @@ -6,25 +6,22 @@ API_URL = fetch_api_url().rstrip("/") + "/batch/jobs/history_list" API_KEY = fetch_api_key() -print(f"Fetching job history from AWS...") +print("Fetching job history from AWS...") print(f"Endpoint: {API_URL}") -headers = { - "x-api-key": API_KEY, - "Accept": "application/json" -} +headers = {"x-api-key": API_KEY, "Accept": "application/json"} try: response = requests.get(API_URL, headers=headers) response.raise_for_status() response_json = response.json() - + print("\n=== CLOUD RESPONSE ===") print(f"Status Code: {response.status_code}") print("======================\n") - + jobs = response_json.get("jobs", []) - + if not jobs: print("No jobs found in history.") else: @@ -36,7 +33,7 @@ except requests.HTTPError as e: print(f"HTTP error: {e}") - if hasattr(e, 'response') and hasattr(e.response, 'text'): + if hasattr(e, "response") and hasattr(e.response, "text"): print(f"Response: {e.response.text}") except Exception as e: - print(f"Failed to fetch job history: {e}") \ No newline at end of file + print(f"Failed to fetch job history: {e}") diff --git a/lifewatch_batch_platform/terraform/client_requests/get_job_logs.py b/lifewatch_batch_platform/terraform/client_requests/get_job_logs.py index d1dab6f..1841565 100644 --- a/lifewatch_batch_platform/terraform/client_requests/get_job_logs.py +++ b/lifewatch_batch_platform/terraform/client_requests/get_job_logs.py @@ -14,9 +14,7 @@ url = f"{API_BASE_URL}/batch/jobs/{job_id}/logs" -headers = { - "x-api-key": API_KEY -} +headers = {"x-api-key": API_KEY} try: response = requests.get(url, headers=headers) @@ -26,4 +24,4 @@ print(response.text) except Exception as e: - print(f"Request failed: {e}") \ No newline at end of file + print(f"Request failed: {e}") diff --git a/lifewatch_batch_platform/terraform/client_requests/get_job_results.py b/lifewatch_batch_platform/terraform/client_requests/get_job_results.py index daab685..437b4a5 100644 --- a/lifewatch_batch_platform/terraform/client_requests/get_job_results.py +++ b/lifewatch_batch_platform/terraform/client_requests/get_job_results.py @@ -30,7 +30,7 @@ except requests.HTTPError as e: print(f"HTTP error: {e}") - if 'response' in locals() and hasattr(response, 'text'): + if "response" in locals() and hasattr(response, "text"): print(f"Response: {response.text}") except Exception as e: - print(f"Error: {e}") \ No newline at end of file + print(f"Error: {e}") diff --git a/lifewatch_batch_platform/terraform/client_requests/get_job_status.py b/lifewatch_batch_platform/terraform/client_requests/get_job_status.py index f1f5aa3..97d7539 100644 --- a/lifewatch_batch_platform/terraform/client_requests/get_job_status.py +++ b/lifewatch_batch_platform/terraform/client_requests/get_job_status.py @@ -14,9 +14,7 @@ url = f"{API_BASE_URL}/batch/jobs/{job_id}" -headers = { - "x-api-key": API_KEY -} +headers = {"x-api-key": API_KEY} try: response = requests.get(url, headers=headers) @@ -26,4 +24,4 @@ print(response.text) except Exception as e: - print(f"Request failed: {e}") \ No newline at end of file + print(f"Request failed: {e}") diff --git a/lifewatch_batch_platform/terraform/client_requests/post_job.py b/lifewatch_batch_platform/terraform/client_requests/post_job.py index 9f6c56a..f7c3972 100644 --- a/lifewatch_batch_platform/terraform/client_requests/post_job.py +++ b/lifewatch_batch_platform/terraform/client_requests/post_job.py @@ -13,26 +13,30 @@ print(f"Sending {NOTEBOOK_PATH} and {DATA_FILE_PATH} to AWS...") print(f"Execution profile: {EXECUTION_PROFILE}") -headers = { - "x-api-key": API_KEY, - "Accept": "multipart/form-data" -} +headers = {"x-api-key": API_KEY, "Accept": "multipart/form-data"} -with open(NOTEBOOK_PATH, 'rb') as nb_file, open(DATA_FILE_PATH, 'rb') as data_file, open(ENV_FILE_PATH, 'rb') as env_file: +with ( + open(NOTEBOOK_PATH, "rb") as nb_file, + open(DATA_FILE_PATH, "rb") as data_file, + open(ENV_FILE_PATH, "rb") as env_file, +): files = { - 'notebook': (NOTEBOOK_PATH, nb_file.read(), 'application/x-ipynb+json'), - 'upload_01': (DATA_FILE_PATH, data_file.read(), 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'), - 'environment': (ENV_FILE_PATH, env_file.read(), 'application/x-yaml'), - + "notebook": (NOTEBOOK_PATH, nb_file.read(), "application/x-ipynb+json"), + "upload_01": ( + DATA_FILE_PATH, + data_file.read(), + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), + "environment": (ENV_FILE_PATH, env_file.read(), "application/x-yaml"), # R parameters - 'param_01_input_data_filename': (None, 'Template_MBO_Example_raw_v3.xlsx'), - 'param_02_input_data_sheet': (None, 'BIRDS'), - 'param_03_input_metadata_sheet': (None, 'METADATA'), - 'param_04_output_samples_ecological_parameters': (None, 'false'), - 'param_05_output_make_plots': (None, 'true'), - 'param_07_first_month': (None, '1'), - 'param_10_upper_limit_max_depth': (None, '1'), - 'execution_profile': (None, EXECUTION_PROFILE) + "param_01_input_data_filename": (None, "Template_MBO_Example_raw_v3.xlsx"), + "param_02_input_data_sheet": (None, "BIRDS"), + "param_03_input_metadata_sheet": (None, "METADATA"), + "param_04_output_samples_ecological_parameters": (None, "false"), + "param_05_output_make_plots": (None, "true"), + "param_07_first_month": (None, "1"), + "param_10_upper_limit_max_depth": (None, "1"), + "execution_profile": (None, EXECUTION_PROFILE), } try: @@ -41,14 +45,16 @@ response_json = response.json() print("\n=== CLOUD RESPONSE ===") print(f"Status Code: {response.status_code}") - print(f"Response:{response_json.get('message', response_json.get('text', 'No message available'))}") + print( + f"Response:{response_json.get('message', response_json.get('text', 'No message available'))}" + ) job_id = response_json.get("job_id") print("======================") if job_id: print("Job ID:", job_id) except requests.HTTPError as e: print(f"HTTP error: {e}") - if 'response' in locals() and hasattr(response, 'text'): + if "response" in locals() and hasattr(response, "text"): print(f"Response: {response.text}") except Exception as e: print(f"Failed to post job: {e}") diff --git a/lifewatch_batch_platform/terraform/environments/dev/backend.tf b/lifewatch_batch_platform/terraform/environments/dev/backend.tf index 18f00af..c02958c 100644 --- a/lifewatch_batch_platform/terraform/environments/dev/backend.tf +++ b/lifewatch_batch_platform/terraform/environments/dev/backend.tf @@ -1,9 +1,9 @@ -terraform { - backend "s3" { - bucket = "lifewatch-terraform-state-eu-west-1" - key = "lifewatch-high-compute/dev/terraform.tfstate" - region = "eu-west-1" - dynamodb_table = "lifewatch-terraform-locks" - encrypt = true - } -} \ No newline at end of file +terraform { + backend "s3" { + bucket = "lifewatch-terraform-state-eu-west-1" + key = "lifewatch-high-compute/dev/terraform.tfstate" + region = "eu-west-1" + dynamodb_table = "lifewatch-terraform-locks" + encrypt = true + } +} diff --git a/lifewatch_batch_platform/terraform/environments/dev/provider.tf b/lifewatch_batch_platform/terraform/environments/dev/provider.tf index ac8fb1b..e62fc36 100644 --- a/lifewatch_batch_platform/terraform/environments/dev/provider.tf +++ b/lifewatch_batch_platform/terraform/environments/dev/provider.tf @@ -1,3 +1,3 @@ -provider "aws" { - region = "eu-west-1" -} \ No newline at end of file +provider "aws" { + region = "eu-west-1" +} diff --git a/lifewatch_batch_platform/terraform/environments/dev/variables.tf b/lifewatch_batch_platform/terraform/environments/dev/variables.tf index ebc6511..56283a5 100644 --- a/lifewatch_batch_platform/terraform/environments/dev/variables.tf +++ b/lifewatch_batch_platform/terraform/environments/dev/variables.tf @@ -195,4 +195,4 @@ variable "rate_limit" { description = "API Gateway usage plan steady-state rate limit (requests per second)." type = number default = 10 -} \ No newline at end of file +} diff --git a/lifewatch_batch_platform/terraform/modules/api_gateway/main.tf b/lifewatch_batch_platform/terraform/modules/api_gateway/main.tf index 6865ce6..d2bfc05 100644 --- a/lifewatch_batch_platform/terraform/modules/api_gateway/main.tf +++ b/lifewatch_batch_platform/terraform/modules/api_gateway/main.tf @@ -218,10 +218,10 @@ locals { resource "aws_api_gateway_method" "options" { for_each = local.cors_routes - rest_api_id = aws_api_gateway_rest_api.api.id - resource_id = each.value.resource_id - http_method = "OPTIONS" - authorization = "NONE" + rest_api_id = aws_api_gateway_rest_api.api.id + resource_id = each.value.resource_id + http_method = "OPTIONS" + authorization = "NONE" api_key_required = false } @@ -331,4 +331,4 @@ resource "aws_api_gateway_stage" "stage" { rest_api_id = aws_api_gateway_rest_api.api.id deployment_id = aws_api_gateway_deployment.deployment.id stage_name = var.stage_name -} \ No newline at end of file +} diff --git a/lifewatch_batch_platform/terraform/modules/api_gateway/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/api_gateway/tests/test.tftest.hcl index 6e4a492..56ee376 100644 --- a/lifewatch_batch_platform/terraform/modules/api_gateway/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/api_gateway/tests/test.tftest.hcl @@ -1,157 +1,157 @@ -# modules/api_gateway/tests/api_gateway_unit.tftest.hcl - -mock_provider "aws" {} - -variables { - project_name = "test-project" - stage_name = "test" - - batch_trigger_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-batch/invocations" - job_status_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-status/invocations" - job_logs_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-logs/invocations" - job_results_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-results/invocations" - job_history_list_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-history/invocations" -} - -# ── Naming ──────────────────────────────────────────────────────────────────── - -run "api_is_named_correctly" { - command = plan - - assert { - condition = aws_api_gateway_rest_api.api.name == "test-project-api" - error_message = "REST API name must follow the -api convention." - } -} - -run "stage_name_passed_through_correctly" { - command = plan - - assert { - condition = aws_api_gateway_stage.stage.stage_name == "test" - error_message = "Stage name does not match the input variable." - } -} - -# ── CORS coverage ───────────────────────────────────────────────────────────── - -run "all_five_cors_routes_have_options_method" { - command = plan - - assert { - condition = length(aws_api_gateway_method.options) == 5 - error_message = "Expected OPTIONS methods for all 5 routes: jobs, history_list, job_id, job_logs, job_results." - } -} - -run "all_options_integrations_are_mock_type" { - command = plan - - assert { - condition = alltrue([ - for k, v in aws_api_gateway_integration.options : v.type == "MOCK" - ]) - error_message = "All OPTIONS integrations must be MOCK — Lambda must never be invoked for preflight." - } -} - -run "cors_response_headers_are_set_on_all_routes" { - command = plan - - assert { - condition = alltrue([ - for k, v in aws_api_gateway_integration_response.options : - lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Origin", null) != null && - lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Methods", null) != null && - lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Headers", null) != null - ]) - error_message = "All CORS integration responses must set Origin, Methods, and Headers response parameters." - } -} - -run "cors_allow_headers_includes_x_api_key" { - command = plan - - assert { - condition = alltrue([ - for k, v in aws_api_gateway_integration_response.options : - can(regex("x-api-key", v.response_parameters["method.response.header.Access-Control-Allow-Headers"])) - ]) - error_message = "CORS Allow-Headers must include x-api-key — required by browser preflight for API key auth." - } -} - -# ── Lambda integrations ─────────────────────────────────────────────────────── - -run "all_lambda_integrations_are_aws_proxy" { - command = plan - - assert { - condition = alltrue([ - aws_api_gateway_integration.post_jobs_lambda.type == "AWS_PROXY", - aws_api_gateway_integration.get_history_list_lambda.type == "AWS_PROXY", - aws_api_gateway_integration.job_status_lambda.type == "AWS_PROXY", - aws_api_gateway_integration.logs_lambda.type == "AWS_PROXY", - aws_api_gateway_integration.job_results_lambda.type == "AWS_PROXY", - ]) - error_message = "All Lambda integrations must be AWS_PROXY type." - } -} - -run "all_lambda_integrations_use_post_method" { - command = plan - - # API Gateway always invokes Lambda via POST regardless of the client-facing HTTP method. - assert { - condition = alltrue([ - aws_api_gateway_integration.post_jobs_lambda.integration_http_method == "POST", - aws_api_gateway_integration.get_history_list_lambda.integration_http_method == "POST", - aws_api_gateway_integration.job_status_lambda.integration_http_method == "POST", - aws_api_gateway_integration.logs_lambda.integration_http_method == "POST", - aws_api_gateway_integration.job_results_lambda.integration_http_method == "POST", - ]) - error_message = "All Lambda integrations must use POST as the integration HTTP method." - } -} - -# ── API key requirement ─────────────────────────────────────────────────────── - -run "all_non_options_methods_require_api_key" { - command = plan - - assert { - condition = alltrue([ - aws_api_gateway_method.post_jobs.api_key_required, - aws_api_gateway_method.get_history_list.api_key_required, - aws_api_gateway_method.get_job_status.api_key_required, - aws_api_gateway_method.get_logs.api_key_required, - aws_api_gateway_method.get_job_results.api_key_required, - ]) - error_message = "All non-OPTIONS methods must have api_key_required = true." - } -} - -run "options_methods_do_not_require_api_key" { - command = plan - - assert { - condition = alltrue([ - for k, v in aws_api_gateway_method.options : !coalesce(v.api_key_required, false) - ]) - error_message = "OPTIONS methods must NOT require an API key — this breaks browser CORS preflight." - } -} - -# ── Binary media types ──────────────────────────────────────────────────────── - -run "api_supports_multipart_binary_media" { - command = plan - - assert { - condition = contains( - aws_api_gateway_rest_api.api.binary_media_types, - "multipart/form-data" - ) - error_message = "REST API must declare multipart/form-data as a binary media type." - } -} \ No newline at end of file +# modules/api_gateway/tests/api_gateway_unit.tftest.hcl + +mock_provider "aws" {} + +variables { + project_name = "test-project" + stage_name = "test" + + batch_trigger_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-batch/invocations" + job_status_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-status/invocations" + job_logs_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-logs/invocations" + job_results_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-results/invocations" + job_history_list_lambda_arn = "arn:aws:apigateway:eu-west-1:lambda:path/2015-03-31/functions/arn:aws:lambda:eu-west-1:123456789012:function:stub-history/invocations" +} + +# ── Naming ──────────────────────────────────────────────────────────────────── + +run "api_is_named_correctly" { + command = plan + + assert { + condition = aws_api_gateway_rest_api.api.name == "test-project-api" + error_message = "REST API name must follow the -api convention." + } +} + +run "stage_name_passed_through_correctly" { + command = plan + + assert { + condition = aws_api_gateway_stage.stage.stage_name == "test" + error_message = "Stage name does not match the input variable." + } +} + +# ── CORS coverage ───────────────────────────────────────────────────────────── + +run "all_five_cors_routes_have_options_method" { + command = plan + + assert { + condition = length(aws_api_gateway_method.options) == 5 + error_message = "Expected OPTIONS methods for all 5 routes: jobs, history_list, job_id, job_logs, job_results." + } +} + +run "all_options_integrations_are_mock_type" { + command = plan + + assert { + condition = alltrue([ + for k, v in aws_api_gateway_integration.options : v.type == "MOCK" + ]) + error_message = "All OPTIONS integrations must be MOCK — Lambda must never be invoked for preflight." + } +} + +run "cors_response_headers_are_set_on_all_routes" { + command = plan + + assert { + condition = alltrue([ + for k, v in aws_api_gateway_integration_response.options : + lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Origin", null) != null && + lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Methods", null) != null && + lookup(v.response_parameters, "method.response.header.Access-Control-Allow-Headers", null) != null + ]) + error_message = "All CORS integration responses must set Origin, Methods, and Headers response parameters." + } +} + +run "cors_allow_headers_includes_x_api_key" { + command = plan + + assert { + condition = alltrue([ + for k, v in aws_api_gateway_integration_response.options : + can(regex("x-api-key", v.response_parameters["method.response.header.Access-Control-Allow-Headers"])) + ]) + error_message = "CORS Allow-Headers must include x-api-key — required by browser preflight for API key auth." + } +} + +# ── Lambda integrations ─────────────────────────────────────────────────────── + +run "all_lambda_integrations_are_aws_proxy" { + command = plan + + assert { + condition = alltrue([ + aws_api_gateway_integration.post_jobs_lambda.type == "AWS_PROXY", + aws_api_gateway_integration.get_history_list_lambda.type == "AWS_PROXY", + aws_api_gateway_integration.job_status_lambda.type == "AWS_PROXY", + aws_api_gateway_integration.logs_lambda.type == "AWS_PROXY", + aws_api_gateway_integration.job_results_lambda.type == "AWS_PROXY", + ]) + error_message = "All Lambda integrations must be AWS_PROXY type." + } +} + +run "all_lambda_integrations_use_post_method" { + command = plan + + # API Gateway always invokes Lambda via POST regardless of the client-facing HTTP method. + assert { + condition = alltrue([ + aws_api_gateway_integration.post_jobs_lambda.integration_http_method == "POST", + aws_api_gateway_integration.get_history_list_lambda.integration_http_method == "POST", + aws_api_gateway_integration.job_status_lambda.integration_http_method == "POST", + aws_api_gateway_integration.logs_lambda.integration_http_method == "POST", + aws_api_gateway_integration.job_results_lambda.integration_http_method == "POST", + ]) + error_message = "All Lambda integrations must use POST as the integration HTTP method." + } +} + +# ── API key requirement ─────────────────────────────────────────────────────── + +run "all_non_options_methods_require_api_key" { + command = plan + + assert { + condition = alltrue([ + aws_api_gateway_method.post_jobs.api_key_required, + aws_api_gateway_method.get_history_list.api_key_required, + aws_api_gateway_method.get_job_status.api_key_required, + aws_api_gateway_method.get_logs.api_key_required, + aws_api_gateway_method.get_job_results.api_key_required, + ]) + error_message = "All non-OPTIONS methods must have api_key_required = true." + } +} + +run "options_methods_do_not_require_api_key" { + command = plan + + assert { + condition = alltrue([ + for k, v in aws_api_gateway_method.options : !coalesce(v.api_key_required, false) + ]) + error_message = "OPTIONS methods must NOT require an API key — this breaks browser CORS preflight." + } +} + +# ── Binary media types ──────────────────────────────────────────────────────── + +run "api_supports_multipart_binary_media" { + command = plan + + assert { + condition = contains( + aws_api_gateway_rest_api.api.binary_media_types, + "multipart/form-data" + ) + error_message = "REST API must declare multipart/form-data as a binary media type." + } +} diff --git a/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/.terraform.lock.hcl index 9301f4a..6ef30f4 100644 --- a/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/.terraform.lock.hcl +++ b/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.36.0" hashes = [ "h1:I36O/YXrM2U+wQd+ncqAoPM/LwODXAHelhN2alctC94=", + "h1:iPIfPP5Nb78QlvCFxpzR+KVPM+PHr86SkNTqgoTZUZs=", "zh:0eb4481315564aaeec4905a804fd0df22c40f509ad2af63615eeaa90abacf81c", "zh:12c3cddc461a8dbaa04387fe83420b64c4c05cb5479d181674168ca7daefcc38", "zh:1b55a09661e80acf6826faa38dd8fbff24c2ef620d2a0a16918491a222c55370", diff --git a/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/tests/test.tftest.hcl index 7f4eefb..73916ad 100644 --- a/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/api_key_usage_plan/tests/test.tftest.hcl @@ -1,119 +1,119 @@ -run "api_key_is_created_with_correct_name" { - command = plan - - variables { - lifewatch_key_name = "test-api-key" - api_id = "abc123def" - stage_name = "dev" - usage_plan_name = "test-usage-plan" - usage_plan_description = "Test usage plan" - burst_limit = 5 - rate_limit = 10 - quota_limit = null - quota_offset = 0 - quota_period = "WEEK" - } - - assert { - condition = aws_api_gateway_api_key.this.name == "test-api-key" - error_message = "API key name should match lifewatch_key_name variable." - } -} - -run "api_key_is_enabled_by_default" { - command = plan - - variables { - lifewatch_key_name = "test-api-key" - api_id = "abc123def" - stage_name = "dev" - usage_plan_name = "test-usage-plan" - usage_plan_description = "Test usage plan" - burst_limit = 5 - rate_limit = 10 - quota_limit = null - quota_offset = 0 - quota_period = "WEEK" - } - - assert { - condition = aws_api_gateway_api_key.this.enabled == true - error_message = "API key should be enabled by default." - } -} - -run "usage_plan_is_created_with_correct_name_and_description" { - command = plan - - variables { - lifewatch_key_name = "test-api-key" - api_id = "abc123def" - stage_name = "dev" - usage_plan_name = "test-usage-plan" - usage_plan_description = "Test usage plan description" - burst_limit = 5 - rate_limit = 10 - quota_limit = null - quota_offset = 0 - quota_period = "WEEK" - } - - assert { - condition = aws_api_gateway_usage_plan.this.name == "test-usage-plan" - error_message = "Usage plan name should match usage_plan_name variable." - } - - assert { - condition = aws_api_gateway_usage_plan.this.description == "Test usage plan description" - error_message = "Usage plan description should match usage_plan_description variable." - } -} - -run "throttle_settings_are_applied_correctly" { - command = plan - - variables { - lifewatch_key_name = "test-api-key" - api_id = "abc123def" - stage_name = "dev" - usage_plan_name = "test-usage-plan" - usage_plan_description = "Test usage plan" - burst_limit = 5 - rate_limit = 10 - quota_limit = null - quota_offset = 0 - quota_period = "WEEK" - } - - assert { - condition = aws_api_gateway_usage_plan.this.throttle_settings[0].burst_limit == 5 - error_message = "Burst limit should match burst_limit variable." - } - - assert { - condition = aws_api_gateway_usage_plan.this.throttle_settings[0].rate_limit == 10 - error_message = "Rate limit should match rate_limit variable." - } -} - -run "quota_is_not_created_when_quota_limit_is_null" { - command = plan - - variables { - lifewatch_key_name = "test-api-key" - api_id = "abc123def" - stage_name = "dev" - usage_plan_name = "test-usage-plan" - usage_plan_description = "Test usage plan" - burst_limit = 5 - rate_limit = 10 - quota_limit = null - quota_offset = 0 - quota_period = "WEEK" - } - - assert { - condition = length(aws_api_gateway_usage_plan.this.quota_settings) == 0 - error_message = "Quota settings should not be created when quota_limit is null." - } -} +run "api_key_is_created_with_correct_name" { + command = plan + + variables { + lifewatch_key_name = "test-api-key" + api_id = "abc123def" + stage_name = "dev" + usage_plan_name = "test-usage-plan" + usage_plan_description = "Test usage plan" + burst_limit = 5 + rate_limit = 10 + quota_limit = null + quota_offset = 0 + quota_period = "WEEK" + } + + assert { + condition = aws_api_gateway_api_key.this.name == "test-api-key" + error_message = "API key name should match lifewatch_key_name variable." + } +} + +run "api_key_is_enabled_by_default" { + command = plan + + variables { + lifewatch_key_name = "test-api-key" + api_id = "abc123def" + stage_name = "dev" + usage_plan_name = "test-usage-plan" + usage_plan_description = "Test usage plan" + burst_limit = 5 + rate_limit = 10 + quota_limit = null + quota_offset = 0 + quota_period = "WEEK" + } + + assert { + condition = aws_api_gateway_api_key.this.enabled == true + error_message = "API key should be enabled by default." + } +} + +run "usage_plan_is_created_with_correct_name_and_description" { + command = plan + + variables { + lifewatch_key_name = "test-api-key" + api_id = "abc123def" + stage_name = "dev" + usage_plan_name = "test-usage-plan" + usage_plan_description = "Test usage plan description" + burst_limit = 5 + rate_limit = 10 + quota_limit = null + quota_offset = 0 + quota_period = "WEEK" + } + + assert { + condition = aws_api_gateway_usage_plan.this.name == "test-usage-plan" + error_message = "Usage plan name should match usage_plan_name variable." + } + + assert { + condition = aws_api_gateway_usage_plan.this.description == "Test usage plan description" + error_message = "Usage plan description should match usage_plan_description variable." + } +} + +run "throttle_settings_are_applied_correctly" { + command = plan + + variables { + lifewatch_key_name = "test-api-key" + api_id = "abc123def" + stage_name = "dev" + usage_plan_name = "test-usage-plan" + usage_plan_description = "Test usage plan" + burst_limit = 5 + rate_limit = 10 + quota_limit = null + quota_offset = 0 + quota_period = "WEEK" + } + + assert { + condition = aws_api_gateway_usage_plan.this.throttle_settings[0].burst_limit == 5 + error_message = "Burst limit should match burst_limit variable." + } + + assert { + condition = aws_api_gateway_usage_plan.this.throttle_settings[0].rate_limit == 10 + error_message = "Rate limit should match rate_limit variable." + } +} + +run "quota_is_not_created_when_quota_limit_is_null" { + command = plan + + variables { + lifewatch_key_name = "test-api-key" + api_id = "abc123def" + stage_name = "dev" + usage_plan_name = "test-usage-plan" + usage_plan_description = "Test usage plan" + burst_limit = 5 + rate_limit = 10 + quota_limit = null + quota_offset = 0 + quota_period = "WEEK" + } + + assert { + condition = length(aws_api_gateway_usage_plan.this.quota_settings) == 0 + error_message = "Quota settings should not be created when quota_limit is null." + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/.terraform.lock.hcl index 9301f4a..5024a0c 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/.terraform.lock.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.36.0" hashes = [ "h1:I36O/YXrM2U+wQd+ncqAoPM/LwODXAHelhN2alctC94=", + "h1:vt/d5HTCzacEmw5c6xuk6JcMc4iXvJ8RVQzelZLzujI=", "zh:0eb4481315564aaeec4905a804fd0df22c40f509ad2af63615eeaa90abacf81c", "zh:12c3cddc461a8dbaa04387fe83420b64c4c05cb5479d181674168ca7daefcc38", "zh:1b55a09661e80acf6826faa38dd8fbff24c2ef620d2a0a16918491a222c55370", diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/main.tf b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/main.tf index df4ed82..8a9d3d3 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/main.tf +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/main.tf @@ -74,4 +74,3 @@ resource "aws_batch_compute_environment" "ec2" { Name = "${var.project_name}-${var.profile_name}-environment" }) } - diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/test/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/test/test.tftest.hcl index 813402d..616b9bc 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/test/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_compute_ec2/test/test.tftest.hcl @@ -1,48 +1,48 @@ -run "validate_batch_compute_environment" { - command = plan - - variables { - project_name = "test-project" - profile_name = "dev" - service_role_arn = "arn:aws:iam::123456789012:role/AWSBatchServiceRole" - instance_profile_arn = "arn:aws:iam::123456789012:instance-profile/ecsInstanceRole" - allocation_strategy = "BEST_FIT_PROGRESSIVE" - max_vcpus = 16 - instance_types = ["t3.medium"] - subnet_ids = ["subnet-12345678"] - security_group_ids = ["sg-12345678"] - - ebs_iops = 3000 - ebs_throughput = 125 - ebs_volume_size_gb = 50 - - tags = { - Environment = "test" - } - } - - assert { - condition = aws_batch_compute_environment.ec2.type == "MANAGED" - error_message = "Compute environment should be MANAGED" - } - - assert { - condition = aws_batch_compute_environment.ec2.compute_resources[0].type == "EC2" - error_message = "Compute resources type should be EC2" - } - - assert { - condition = aws_batch_compute_environment.ec2.compute_resources[0].max_vcpus == 16 - error_message = "Max vCPUs should match input" - } - - assert { - condition = aws_launch_template.batch_ec2.block_device_mappings[0].ebs[0].volume_type == "gp3" - error_message = "EBS volume type should be gp3" - } - - assert { - condition = aws_launch_template.batch_ec2.metadata_options[0].http_tokens == "required" - error_message = "IMDSv2 should be enforced" - } -} \ No newline at end of file +run "validate_batch_compute_environment" { + command = plan + + variables { + project_name = "test-project" + profile_name = "dev" + service_role_arn = "arn:aws:iam::123456789012:role/AWSBatchServiceRole" + instance_profile_arn = "arn:aws:iam::123456789012:instance-profile/ecsInstanceRole" + allocation_strategy = "BEST_FIT_PROGRESSIVE" + max_vcpus = 16 + instance_types = ["t3.medium"] + subnet_ids = ["subnet-12345678"] + security_group_ids = ["sg-12345678"] + + ebs_iops = 3000 + ebs_throughput = 125 + ebs_volume_size_gb = 50 + + tags = { + Environment = "test" + } + } + + assert { + condition = aws_batch_compute_environment.ec2.type == "MANAGED" + error_message = "Compute environment should be MANAGED" + } + + assert { + condition = aws_batch_compute_environment.ec2.compute_resources[0].type == "EC2" + error_message = "Compute resources type should be EC2" + } + + assert { + condition = aws_batch_compute_environment.ec2.compute_resources[0].max_vcpus == 16 + error_message = "Max vCPUs should match input" + } + + assert { + condition = aws_launch_template.batch_ec2.block_device_mappings[0].ebs[0].volume_type == "gp3" + error_message = "EBS volume type should be gp3" + } + + assert { + condition = aws_launch_template.batch_ec2.metadata_options[0].http_tokens == "required" + error_message = "IMDSv2 should be enforced" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/main.tf b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/main.tf index ef1ff60..819c1d5 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/main.tf +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/main.tf @@ -24,4 +24,3 @@ resource "aws_batch_job_definition" "ec2" { Name = "${var.project_name}-${var.profile_name}-job-definition" }) } - diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/test/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/test/test.tftest.hcl index 4672509..44b9421 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/test/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_job_definition_ec2/test/test.tftest.hcl @@ -1,48 +1,48 @@ -run "ec2_job_definition" { - command = plan - - variables { - project_name = "test" - profile_name = "dev" - container_image = "nginx" - container_command = ["echo", "hello"] - vcpus = 2 - memory_mib = 1024 - job_role_arn = "arn:aws:iam::123456789012:role/test-role" - - s3_bucket_arn = "arn:aws:s3:::dummy-bucket" - - tags = { - Env = "test" - } - } - - # Ensure correct platform - assert { - condition = contains(aws_batch_job_definition.ec2.platform_capabilities, "EC2") - error_message = "Job definition must support EC2" - } - - # Ensure CPU/memory wired correctly (JSON decode) - assert { - condition = jsondecode( - aws_batch_job_definition.ec2.container_properties - ).vcpus == 2 - error_message = "vCPUs not wired correctly" - } - - assert { - condition = jsondecode( - aws_batch_job_definition.ec2.container_properties - ).memory == 1024 - error_message = "Memory not wired correctly" - } - - # Ensure role is passed - assert { - condition = jsondecode( - aws_batch_job_definition.ec2.container_properties - ).jobRoleArn != "" - error_message = "Job role must be set" - } -} \ No newline at end of file +run "ec2_job_definition" { + command = plan + + variables { + project_name = "test" + profile_name = "dev" + container_image = "nginx" + container_command = ["echo", "hello"] + vcpus = 2 + memory_mib = 1024 + job_role_arn = "arn:aws:iam::123456789012:role/test-role" + + s3_bucket_arn = "arn:aws:s3:::dummy-bucket" + + tags = { + Env = "test" + } + } + + # Ensure correct platform + assert { + condition = contains(aws_batch_job_definition.ec2.platform_capabilities, "EC2") + error_message = "Job definition must support EC2" + } + + # Ensure CPU/memory wired correctly (JSON decode) + assert { + condition = jsondecode( + aws_batch_job_definition.ec2.container_properties + ).vcpus == 2 + error_message = "vCPUs not wired correctly" + } + + assert { + condition = jsondecode( + aws_batch_job_definition.ec2.container_properties + ).memory == 1024 + error_message = "Memory not wired correctly" + } + + # Ensure role is passed + assert { + condition = jsondecode( + aws_batch_job_definition.ec2.container_properties + ).jobRoleArn != "" + error_message = "Job role must be set" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_queue_ec2/test/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_queue_ec2/test/test.tftest.hcl index f1c0e39..ef0ae02 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_queue_ec2/test/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_ec2/batch_queue_ec2/test/test.tftest.hcl @@ -1,30 +1,30 @@ -run "ec2_job_queue" { - command = plan - - variables { - project_name = "test" - profile_name = "dev" - priority = 10 - compute_environment_arn = "arn:aws:batch:region:acct:compute-environment/test" - runnable_timeout_seconds = 3600 - - tags = { - Env = "test" - } - } - - assert { - condition = aws_batch_job_queue.ec2.priority == 10 - error_message = "Priority not set correctly" - } - - assert { - condition = aws_batch_job_queue.ec2.compute_environment_order[0].compute_environment != "" - error_message = "Compute environment must be attached" - } - - assert { - condition = aws_batch_job_queue.ec2.job_state_time_limit_action[0].max_time_seconds == 3600 - error_message = "Runnable timeout not wired correctly" - } -} \ No newline at end of file +run "ec2_job_queue" { + command = plan + + variables { + project_name = "test" + profile_name = "dev" + priority = 10 + compute_environment_arn = "arn:aws:batch:region:acct:compute-environment/test" + runnable_timeout_seconds = 3600 + + tags = { + Env = "test" + } + } + + assert { + condition = aws_batch_job_queue.ec2.priority == 10 + error_message = "Priority not set correctly" + } + + assert { + condition = aws_batch_job_queue.ec2.compute_environment_order[0].compute_environment != "" + error_message = "Compute environment must be attached" + } + + assert { + condition = aws_batch_job_queue.ec2.job_state_time_limit_action[0].max_time_seconds == 3600 + error_message = "Runnable timeout not wired correctly" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_compute_fargate/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_compute_fargate/tests/test.tftest.hcl index 7a9dc26..d971182 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_compute_fargate/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_compute_fargate/tests/test.tftest.hcl @@ -1,31 +1,31 @@ -run "fargate_compute_env" { - command = plan - - variables { - project_name = "test" - profile_name = "dev" - max_vcpus = 32 - subnet_ids = ["subnet-123"] - security_group_ids = ["sg-123"] - service_role_arn = "arn:aws:iam::123456789012:role/AWSBatchServiceRole" - - tags = { - Env = "test" - } - } - - assert { - condition = aws_batch_compute_environment.fargate.compute_resources[0].type == "FARGATE" - error_message = "Must be FARGATE compute environment" - } - - assert { - condition = aws_batch_compute_environment.fargate.compute_resources[0].max_vcpus == 32 - error_message = "max_vcpus not wired correctly" - } - - assert { - condition = length(aws_batch_compute_environment.fargate.compute_resources[0].subnets) > 0 - error_message = "Subnets must be provided" - } -} \ No newline at end of file +run "fargate_compute_env" { + command = plan + + variables { + project_name = "test" + profile_name = "dev" + max_vcpus = 32 + subnet_ids = ["subnet-123"] + security_group_ids = ["sg-123"] + service_role_arn = "arn:aws:iam::123456789012:role/AWSBatchServiceRole" + + tags = { + Env = "test" + } + } + + assert { + condition = aws_batch_compute_environment.fargate.compute_resources[0].type == "FARGATE" + error_message = "Must be FARGATE compute environment" + } + + assert { + condition = aws_batch_compute_environment.fargate.compute_resources[0].max_vcpus == 32 + error_message = "max_vcpus not wired correctly" + } + + assert { + condition = length(aws_batch_compute_environment.fargate.compute_resources[0].subnets) > 0 + error_message = "Subnets must be provided" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/main.tf b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/main.tf index aa73937..a3d2936 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/main.tf +++ b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/main.tf @@ -23,7 +23,7 @@ resource "aws_batch_job_definition" "fargate" { environment = [] resourceRequirements = [ - { type = "VCPU", value = tostring(var.vcpus) }, + { type = "VCPU", value = tostring(var.vcpus) }, { type = "MEMORY", value = tostring(var.memory_mib) } ] @@ -32,16 +32,15 @@ resource "aws_batch_job_definition" "fargate" { } runtimePlatform = { - cpuArchitecture = "X86_64" + cpuArchitecture = "X86_64" operatingSystemFamily = "LINUX" } executionRoleArn = var.execution_role_arn - jobRoleArn = var.job_role_arn + jobRoleArn = var.job_role_arn }) tags = merge(var.tags, { Name = "${var.project_name}-${var.profile_name}-job-definition" }) } - diff --git a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/tests/test.tftest.hcl index 5d3d88f..26cc326 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_job_definition_fargate/tests/test.tftest.hcl @@ -1,61 +1,61 @@ -run "fargate_job_definition" { - command = plan - - variables { - project_name = "test" - profile_name = "dev" - container_image = "nginx" - container_command = ["run"] - vcpus = 2 - memory_mib = 4096 - ephemeral_storage_gib = 50 - execution_role_arn = "arn:aws:iam::123456789012:role/execution" - job_role_arn = "arn:aws:iam::123456789012:role/job" - s3_bucket_arn = "arn:aws:s3:::dummy-bucket" - - tags = { - Env = "test" - } - } - - assert { - condition = contains( - aws_batch_job_definition.fargate.platform_capabilities, - "FARGATE" - ) - error_message = "Must support FARGATE" - } - - # Validate resourceRequirements mapping - assert { - condition = contains( - [for r in jsondecode(aws_batch_job_definition.fargate.container_properties).resourceRequirements : r.value], - "2" - ) - error_message = "VCPU not correctly mapped" - } - - assert { - condition = contains( - [for r in jsondecode(aws_batch_job_definition.fargate.container_properties).resourceRequirements : r.value], - "4096" - ) - error_message = "Memory not correctly mapped" - } - - # Validate ephemeral storage - assert { - condition = jsondecode( - aws_batch_job_definition.fargate.container_properties - ).ephemeralStorage.sizeInGiB == 50 - error_message = "Ephemeral storage not wired correctly" - } - - # Validate execution role exists - assert { - condition = jsondecode( - aws_batch_job_definition.fargate.container_properties - ).executionRoleArn != "" - error_message = "Execution role must be set" - } -} \ No newline at end of file +run "fargate_job_definition" { + command = plan + + variables { + project_name = "test" + profile_name = "dev" + container_image = "nginx" + container_command = ["run"] + vcpus = 2 + memory_mib = 4096 + ephemeral_storage_gib = 50 + execution_role_arn = "arn:aws:iam::123456789012:role/execution" + job_role_arn = "arn:aws:iam::123456789012:role/job" + s3_bucket_arn = "arn:aws:s3:::dummy-bucket" + + tags = { + Env = "test" + } + } + + assert { + condition = contains( + aws_batch_job_definition.fargate.platform_capabilities, + "FARGATE" + ) + error_message = "Must support FARGATE" + } + + # Validate resourceRequirements mapping + assert { + condition = contains( + [for r in jsondecode(aws_batch_job_definition.fargate.container_properties).resourceRequirements : r.value], + "2" + ) + error_message = "VCPU not correctly mapped" + } + + assert { + condition = contains( + [for r in jsondecode(aws_batch_job_definition.fargate.container_properties).resourceRequirements : r.value], + "4096" + ) + error_message = "Memory not correctly mapped" + } + + # Validate ephemeral storage + assert { + condition = jsondecode( + aws_batch_job_definition.fargate.container_properties + ).ephemeralStorage.sizeInGiB == 50 + error_message = "Ephemeral storage not wired correctly" + } + + # Validate execution role exists + assert { + condition = jsondecode( + aws_batch_job_definition.fargate.container_properties + ).executionRoleArn != "" + error_message = "Execution role must be set" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_queue_fargate/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_queue_fargate/tests/test.tftest.hcl index 5f88821..1d205bb 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_queue_fargate/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_fargate/batch_queue_fargate/tests/test.tftest.hcl @@ -1,30 +1,30 @@ -run "fargate_job_queue" { - command = plan - - variables { - project_name = "test" - profile_name = "dev" - priority = 5 - compute_environment_arn = "arn:aws:batch:region:acct:compute-environment/test" - runnable_timeout_seconds = 1800 - - tags = { - Env = "test" - } - } - - assert { - condition = aws_batch_job_queue.fargate.priority == 5 - error_message = "Priority incorrect" - } - - assert { - condition = aws_batch_job_queue.fargate.compute_environment_order[0].order == 1 - error_message = "Compute environment order must be 1" - } - - assert { - condition = aws_batch_job_queue.fargate.job_state_time_limit_action[0].action == "CANCEL" - error_message = "Runnable jobs must cancel after timeout" - } -} \ No newline at end of file +run "fargate_job_queue" { + command = plan + + variables { + project_name = "test" + profile_name = "dev" + priority = 5 + compute_environment_arn = "arn:aws:batch:region:acct:compute-environment/test" + runnable_timeout_seconds = 1800 + + tags = { + Env = "test" + } + } + + assert { + condition = aws_batch_job_queue.fargate.priority == 5 + error_message = "Priority incorrect" + } + + assert { + condition = aws_batch_job_queue.fargate.compute_environment_order[0].order == 1 + error_message = "Compute environment order must be 1" + } + + assert { + condition = aws_batch_job_queue.fargate.job_state_time_limit_action[0].action == "CANCEL" + error_message = "Runnable jobs must cancel after timeout" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/batch_iam/main.tf b/lifewatch_batch_platform/terraform/modules/batch_iam/main.tf index afcb387..304baee 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_iam/main.tf +++ b/lifewatch_batch_platform/terraform/modules/batch_iam/main.tf @@ -25,7 +25,7 @@ resource "aws_iam_role_policy_attachment" "batch_service_role" { resource "aws_iam_role_policy" "batch_service_custom_policy" { name = "${var.project_name}-batch-custom-policy" role = aws_iam_role.batch_service_role.name - + policy = jsonencode({ Version = "2012-10-17" Statement = [ @@ -104,8 +104,8 @@ resource "aws_iam_role" "batch_job_role" { } resource "aws_iam_role_policy" "batch_job_s3" { - name = "${var.project_name}-batch-job-s3" - role = aws_iam_role.batch_job_role.id + name = "${var.project_name}-batch-job-s3" + role = aws_iam_role.batch_job_role.id policy = jsonencode({ Version = "2012-10-17" diff --git a/lifewatch_batch_platform/terraform/modules/batch_iam/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/batch_iam/tests/test.tftest.hcl index 1e57f4a..cd278d5 100644 --- a/lifewatch_batch_platform/terraform/modules/batch_iam/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/batch_iam/tests/test.tftest.hcl @@ -1,93 +1,93 @@ -run "batch_iam_roles" { - command = plan - - variables { - project_name = "test" - s3_bucket_arn = "arn:aws:s3:::my-bucket" - - tags = { - Env = "test" - } - } - - ################################ - # Batch Service Role - ################################ - - assert { - condition = aws_iam_role.batch_service_role.name == "test-batch-service-role" - error_message = "Batch service role name incorrect" - } - - # Validate trust policy - assert { - condition = jsondecode( - aws_iam_role.batch_service_role.assume_role_policy - ).Statement[0].Principal.Service == "batch.amazonaws.com" - error_message = "Batch service role trust policy incorrect" - } - - ################################ - # EC2 Instance Role - ################################ - - assert { - condition = jsondecode( - aws_iam_role.ec2_instance_role.assume_role_policy - ).Statement[0].Principal.Service == "ec2.amazonaws.com" - error_message = "EC2 role must be assumable by EC2" - } - - # Ensure instance profile is linked - assert { - condition = aws_iam_instance_profile.ec2_instance_profile.role == aws_iam_role.ec2_instance_role.name - error_message = "Instance profile not linked to EC2 role" - } - - ################################ - # Job Role - ################################ - - assert { - condition = jsondecode( - aws_iam_role.batch_job_role.assume_role_policy - ).Statement[0].Principal.Service == "ecs-tasks.amazonaws.com" - error_message = "Job role must be assumable by ECS tasks" - } - - ################################ - # S3 Policy Wiring (HIGH VALUE) - ################################ - - # Check bucket-level permission - assert { - condition = contains( - jsondecode(aws_iam_role_policy.batch_job_s3.policy).Statement[0].Resource, - "arn:aws:s3:::my-bucket" - ) - error_message = "S3 bucket ARN not correctly wired" - } - - # Check object-level permission - assert { - condition = contains( - jsondecode(aws_iam_role_policy.batch_job_s3.policy).Statement[1].Resource, - "arn:aws:s3:::my-bucket/*" - ) - error_message = "S3 object ARN not correctly wired" - } - - ################################ - # Managed Policy Attachments - ################################ - - assert { - condition = aws_iam_role_policy_attachment.ec2_instance_ecs.policy_arn == "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" - error_message = "Missing ECS policy attachment" - } - - assert { - condition = aws_iam_role_policy_attachment.ec2_instance_ecr_readonly.policy_arn == "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" - error_message = "Missing ECR read-only policy" - } -} \ No newline at end of file +run "batch_iam_roles" { + command = plan + + variables { + project_name = "test" + s3_bucket_arn = "arn:aws:s3:::my-bucket" + + tags = { + Env = "test" + } + } + + ################################ + # Batch Service Role + ################################ + + assert { + condition = aws_iam_role.batch_service_role.name == "test-batch-service-role" + error_message = "Batch service role name incorrect" + } + + # Validate trust policy + assert { + condition = jsondecode( + aws_iam_role.batch_service_role.assume_role_policy + ).Statement[0].Principal.Service == "batch.amazonaws.com" + error_message = "Batch service role trust policy incorrect" + } + + ################################ + # EC2 Instance Role + ################################ + + assert { + condition = jsondecode( + aws_iam_role.ec2_instance_role.assume_role_policy + ).Statement[0].Principal.Service == "ec2.amazonaws.com" + error_message = "EC2 role must be assumable by EC2" + } + + # Ensure instance profile is linked + assert { + condition = aws_iam_instance_profile.ec2_instance_profile.role == aws_iam_role.ec2_instance_role.name + error_message = "Instance profile not linked to EC2 role" + } + + ################################ + # Job Role + ################################ + + assert { + condition = jsondecode( + aws_iam_role.batch_job_role.assume_role_policy + ).Statement[0].Principal.Service == "ecs-tasks.amazonaws.com" + error_message = "Job role must be assumable by ECS tasks" + } + + ################################ + # S3 Policy Wiring (HIGH VALUE) + ################################ + + # Check bucket-level permission + assert { + condition = contains( + jsondecode(aws_iam_role_policy.batch_job_s3.policy).Statement[0].Resource, + "arn:aws:s3:::my-bucket" + ) + error_message = "S3 bucket ARN not correctly wired" + } + + # Check object-level permission + assert { + condition = contains( + jsondecode(aws_iam_role_policy.batch_job_s3.policy).Statement[1].Resource, + "arn:aws:s3:::my-bucket/*" + ) + error_message = "S3 object ARN not correctly wired" + } + + ################################ + # Managed Policy Attachments + ################################ + + assert { + condition = aws_iam_role_policy_attachment.ec2_instance_ecs.policy_arn == "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" + error_message = "Missing ECS policy attachment" + } + + assert { + condition = aws_iam_role_policy_attachment.ec2_instance_ecr_readonly.policy_arn == "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" + error_message = "Missing ECR read-only policy" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda/lambda_batch_trigger/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/lambda/lambda_batch_trigger/.terraform.lock.hcl new file mode 100644 index 0000000..7cfddb0 --- /dev/null +++ b/lifewatch_batch_platform/terraform/modules/lambda/lambda_batch_trigger/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.37.0" + hashes = [ + "h1:z+wSA7CUTunt2Kb+O4TKNDT4pmWOovNoUxijZLn3n9w=", + "zh:0427fadb719ed5a32feb09f047539d2348e659056f3b8a8589d34d8f0a95be7a", + "zh:3891c670674aba2125a7ac6d4348cde43646b1b46ce6f829e6f4724091bc0dcd", + "zh:632cb24b7b5790b730b33bcbe9f1a7b75f2644fb52f9d6aaafb0249c9e7601d2", + "zh:6e96ed1f824c2efa9de5b7c22ab3715624ba34c28564a06e9a15e71bc3d3a30b", + "zh:7b8fd86907b659bc45f4a3f42c3c0ccc66925a74e265b01e9e66242c0b2cafef", + "zh:81f9a587deddef4dfcc2101c54ec28a3a554056837f68ebb920c83fe8327b16f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a9a38a67cb98d690fec951ec3e133b6836279629db2ed3a0ebf97a5bea58674f", + "zh:b18f60d62e4bd4d466077e09c39259d1a85355f0f00b801fe8aedbc50193d357", + "zh:b7a51bc0faf60d17043b4df1d1b7bb55129eaa4bdeb65ff55f5b00b9b8fee9f7", + "zh:c28c42f91ca3a6b65b3fd3ed6e891fc0fc28d0cb5ab65dea65eda8eec5cea5f3", + "zh:d895ddc04280ed26b6ca64ca05b78caaa7b72c8e167af4093545efbc608d5482", + "zh:f4a56f5157009ef160fbd79105078fe675df479cb73c1b7e1fea2741403a0b67", + "zh:f547d6ca371b96fec97b972fc0c93bcfc23d58e34a9da215b94e9d2aa170fb77", + "zh:f7b0a3cd4adadd3f4b9609a54e651ed5eafa22c196ab229042fc1d0aa0ab8f3a", + ] +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_history_list/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_history_list/.terraform.lock.hcl new file mode 100644 index 0000000..7cfddb0 --- /dev/null +++ b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_history_list/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.37.0" + hashes = [ + "h1:z+wSA7CUTunt2Kb+O4TKNDT4pmWOovNoUxijZLn3n9w=", + "zh:0427fadb719ed5a32feb09f047539d2348e659056f3b8a8589d34d8f0a95be7a", + "zh:3891c670674aba2125a7ac6d4348cde43646b1b46ce6f829e6f4724091bc0dcd", + "zh:632cb24b7b5790b730b33bcbe9f1a7b75f2644fb52f9d6aaafb0249c9e7601d2", + "zh:6e96ed1f824c2efa9de5b7c22ab3715624ba34c28564a06e9a15e71bc3d3a30b", + "zh:7b8fd86907b659bc45f4a3f42c3c0ccc66925a74e265b01e9e66242c0b2cafef", + "zh:81f9a587deddef4dfcc2101c54ec28a3a554056837f68ebb920c83fe8327b16f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a9a38a67cb98d690fec951ec3e133b6836279629db2ed3a0ebf97a5bea58674f", + "zh:b18f60d62e4bd4d466077e09c39259d1a85355f0f00b801fe8aedbc50193d357", + "zh:b7a51bc0faf60d17043b4df1d1b7bb55129eaa4bdeb65ff55f5b00b9b8fee9f7", + "zh:c28c42f91ca3a6b65b3fd3ed6e891fc0fc28d0cb5ab65dea65eda8eec5cea5f3", + "zh:d895ddc04280ed26b6ca64ca05b78caaa7b72c8e167af4093545efbc608d5482", + "zh:f4a56f5157009ef160fbd79105078fe675df479cb73c1b7e1fea2741403a0b67", + "zh:f547d6ca371b96fec97b972fc0c93bcfc23d58e34a9da215b94e9d2aa170fb77", + "zh:f7b0a3cd4adadd3f4b9609a54e651ed5eafa22c196ab229042fc1d0aa0ab8f3a", + ] +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_logs/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_logs/.terraform.lock.hcl new file mode 100644 index 0000000..7cfddb0 --- /dev/null +++ b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_logs/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.37.0" + hashes = [ + "h1:z+wSA7CUTunt2Kb+O4TKNDT4pmWOovNoUxijZLn3n9w=", + "zh:0427fadb719ed5a32feb09f047539d2348e659056f3b8a8589d34d8f0a95be7a", + "zh:3891c670674aba2125a7ac6d4348cde43646b1b46ce6f829e6f4724091bc0dcd", + "zh:632cb24b7b5790b730b33bcbe9f1a7b75f2644fb52f9d6aaafb0249c9e7601d2", + "zh:6e96ed1f824c2efa9de5b7c22ab3715624ba34c28564a06e9a15e71bc3d3a30b", + "zh:7b8fd86907b659bc45f4a3f42c3c0ccc66925a74e265b01e9e66242c0b2cafef", + "zh:81f9a587deddef4dfcc2101c54ec28a3a554056837f68ebb920c83fe8327b16f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a9a38a67cb98d690fec951ec3e133b6836279629db2ed3a0ebf97a5bea58674f", + "zh:b18f60d62e4bd4d466077e09c39259d1a85355f0f00b801fe8aedbc50193d357", + "zh:b7a51bc0faf60d17043b4df1d1b7bb55129eaa4bdeb65ff55f5b00b9b8fee9f7", + "zh:c28c42f91ca3a6b65b3fd3ed6e891fc0fc28d0cb5ab65dea65eda8eec5cea5f3", + "zh:d895ddc04280ed26b6ca64ca05b78caaa7b72c8e167af4093545efbc608d5482", + "zh:f4a56f5157009ef160fbd79105078fe675df479cb73c1b7e1fea2741403a0b67", + "zh:f547d6ca371b96fec97b972fc0c93bcfc23d58e34a9da215b94e9d2aa170fb77", + "zh:f7b0a3cd4adadd3f4b9609a54e651ed5eafa22c196ab229042fc1d0aa0ab8f3a", + ] +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_results/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_results/.terraform.lock.hcl new file mode 100644 index 0000000..7cfddb0 --- /dev/null +++ b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_results/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.37.0" + hashes = [ + "h1:z+wSA7CUTunt2Kb+O4TKNDT4pmWOovNoUxijZLn3n9w=", + "zh:0427fadb719ed5a32feb09f047539d2348e659056f3b8a8589d34d8f0a95be7a", + "zh:3891c670674aba2125a7ac6d4348cde43646b1b46ce6f829e6f4724091bc0dcd", + "zh:632cb24b7b5790b730b33bcbe9f1a7b75f2644fb52f9d6aaafb0249c9e7601d2", + "zh:6e96ed1f824c2efa9de5b7c22ab3715624ba34c28564a06e9a15e71bc3d3a30b", + "zh:7b8fd86907b659bc45f4a3f42c3c0ccc66925a74e265b01e9e66242c0b2cafef", + "zh:81f9a587deddef4dfcc2101c54ec28a3a554056837f68ebb920c83fe8327b16f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a9a38a67cb98d690fec951ec3e133b6836279629db2ed3a0ebf97a5bea58674f", + "zh:b18f60d62e4bd4d466077e09c39259d1a85355f0f00b801fe8aedbc50193d357", + "zh:b7a51bc0faf60d17043b4df1d1b7bb55129eaa4bdeb65ff55f5b00b9b8fee9f7", + "zh:c28c42f91ca3a6b65b3fd3ed6e891fc0fc28d0cb5ab65dea65eda8eec5cea5f3", + "zh:d895ddc04280ed26b6ca64ca05b78caaa7b72c8e167af4093545efbc608d5482", + "zh:f4a56f5157009ef160fbd79105078fe675df479cb73c1b7e1fea2741403a0b67", + "zh:f547d6ca371b96fec97b972fc0c93bcfc23d58e34a9da215b94e9d2aa170fb77", + "zh:f7b0a3cd4adadd3f4b9609a54e651ed5eafa22c196ab229042fc1d0aa0ab8f3a", + ] +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_status/.terraform.lock.hcl b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_status/.terraform.lock.hcl new file mode 100644 index 0000000..7cfddb0 --- /dev/null +++ b/lifewatch_batch_platform/terraform/modules/lambda/lambda_job_status/.terraform.lock.hcl @@ -0,0 +1,24 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.37.0" + hashes = [ + "h1:z+wSA7CUTunt2Kb+O4TKNDT4pmWOovNoUxijZLn3n9w=", + "zh:0427fadb719ed5a32feb09f047539d2348e659056f3b8a8589d34d8f0a95be7a", + "zh:3891c670674aba2125a7ac6d4348cde43646b1b46ce6f829e6f4724091bc0dcd", + "zh:632cb24b7b5790b730b33bcbe9f1a7b75f2644fb52f9d6aaafb0249c9e7601d2", + "zh:6e96ed1f824c2efa9de5b7c22ab3715624ba34c28564a06e9a15e71bc3d3a30b", + "zh:7b8fd86907b659bc45f4a3f42c3c0ccc66925a74e265b01e9e66242c0b2cafef", + "zh:81f9a587deddef4dfcc2101c54ec28a3a554056837f68ebb920c83fe8327b16f", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a9a38a67cb98d690fec951ec3e133b6836279629db2ed3a0ebf97a5bea58674f", + "zh:b18f60d62e4bd4d466077e09c39259d1a85355f0f00b801fe8aedbc50193d357", + "zh:b7a51bc0faf60d17043b4df1d1b7bb55129eaa4bdeb65ff55f5b00b9b8fee9f7", + "zh:c28c42f91ca3a6b65b3fd3ed6e891fc0fc28d0cb5ab65dea65eda8eec5cea5f3", + "zh:d895ddc04280ed26b6ca64ca05b78caaa7b72c8e167af4093545efbc608d5482", + "zh:f4a56f5157009ef160fbd79105078fe675df479cb73c1b7e1fea2741403a0b67", + "zh:f547d6ca371b96fec97b972fc0c93bcfc23d58e34a9da215b94e9d2aa170fb77", + "zh:f7b0a3cd4adadd3f4b9609a54e651ed5eafa22c196ab229042fc1d0aa0ab8f3a", + ] +} diff --git a/lifewatch_batch_platform/terraform/modules/lambda_iam/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/lambda_iam/tests/test.tftest.hcl index e4dbdbe..7726977 100644 --- a/lifewatch_batch_platform/terraform/modules/lambda_iam/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/lambda_iam/tests/test.tftest.hcl @@ -1,46 +1,46 @@ -run "lambda_iam" { - command = plan - - variables { - project_name = "test" - s3_bucket_arn = "arn:aws:s3:::my-bucket" - - tags = { - Env = "test" - } - } - - # Trust policy - assert { - condition = jsondecode( - aws_iam_role.lambda.assume_role_policy - ).Statement[0].Principal.Service == "lambda.amazonaws.com" - error_message = "Lambda must be assumable by Lambda service" - } - - # S3 access wiring (critical) - assert { - condition = contains( - jsondecode(aws_iam_role_policy.lambda.policy).Statement[0].Resource, - "arn:aws:s3:::my-bucket" - ) - error_message = "S3 bucket ARN missing" - } - - assert { - condition = contains( - jsondecode(aws_iam_role_policy.lambda.policy).Statement[0].Resource, - "arn:aws:s3:::my-bucket/*" - ) - error_message = "S3 object ARN missing" - } - - # Batch permissions - assert { - condition = contains( - jsondecode(aws_iam_role_policy.lambda.policy).Statement[1].Action, - "batch:SubmitJob" - ) - error_message = "Missing Batch SubmitJob permission" - } -} \ No newline at end of file +run "lambda_iam" { + command = plan + + variables { + project_name = "test" + s3_bucket_arn = "arn:aws:s3:::my-bucket" + + tags = { + Env = "test" + } + } + + # Trust policy + assert { + condition = jsondecode( + aws_iam_role.lambda.assume_role_policy + ).Statement[0].Principal.Service == "lambda.amazonaws.com" + error_message = "Lambda must be assumable by Lambda service" + } + + # S3 access wiring (critical) + assert { + condition = contains( + jsondecode(aws_iam_role_policy.lambda.policy).Statement[0].Resource, + "arn:aws:s3:::my-bucket" + ) + error_message = "S3 bucket ARN missing" + } + + assert { + condition = contains( + jsondecode(aws_iam_role_policy.lambda.policy).Statement[0].Resource, + "arn:aws:s3:::my-bucket/*" + ) + error_message = "S3 object ARN missing" + } + + # Batch permissions + assert { + condition = contains( + jsondecode(aws_iam_role_policy.lambda.policy).Statement[1].Action, + "batch:SubmitJob" + ) + error_message = "Missing Batch SubmitJob permission" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/s3_batch_payloads/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/s3_batch_payloads/tests/test.tftest.hcl index acdfe95..4b63e2c 100644 --- a/lifewatch_batch_platform/terraform/modules/s3_batch_payloads/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/s3_batch_payloads/tests/test.tftest.hcl @@ -1,26 +1,26 @@ -run "s3_batch_payloads" { - command = plan - - variables { - project_name = "test" - - tags = { - Env = "test" - } - } - - # Bucket naming includes account ID - assert { - condition = can(regex( - "^test-batch-payloads-", - aws_s3_bucket.batch_payloads.bucket - )) - error_message = "Bucket name must include project prefix" - } - - # Tag check - assert { - condition = aws_s3_bucket.batch_payloads.tags["Name"] == "test-batch-payloads" - error_message = "Name tag incorrect" - } -} \ No newline at end of file +run "s3_batch_payloads" { + command = plan + + variables { + project_name = "test" + + tags = { + Env = "test" + } + } + + # Bucket naming includes account ID + assert { + condition = can(regex( + "^test-batch-payloads-", + aws_s3_bucket.batch_payloads.bucket + )) + error_message = "Bucket name must include project prefix" + } + + # Tag check + assert { + condition = aws_s3_bucket.batch_payloads.tags["Name"] == "test-batch-payloads" + error_message = "Name tag incorrect" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/security_groups/main.tf b/lifewatch_batch_platform/terraform/modules/security_groups/main.tf index a180f9e..2a58999 100644 --- a/lifewatch_batch_platform/terraform/modules/security_groups/main.tf +++ b/lifewatch_batch_platform/terraform/modules/security_groups/main.tf @@ -1,111 +1,111 @@ -################################ -# Batch Security Group -################################ - -resource "aws_security_group" "batch" { - name_prefix = "${var.project_name}-batch-sg" - description = "Security group for Batch compute" - vpc_id = var.vpc_id - - tags = merge(var.tags, { - Name = "${var.project_name}-batch-sg" - }) -} - -################################ -# Batch -> Internet (HTTPS) -################################ - -resource "aws_security_group_rule" "batch_https_out" { - type = "egress" - description = "HTTPS to internet" - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.batch.id -} - -################################ -# Batch -> Internet (HTTP) -################################ - -resource "aws_security_group_rule" "batch_http_out" { - type = "egress" - description = "HTTP to internet" - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.batch.id -} - -################################ -# Batch -> Internet (TCP 9000) -################################ - -resource "aws_security_group_rule" "batch_9000_out" { - type = "egress" - description = "Allow TCP 9000 to internet" - from_port = 9000 - to_port = 9000 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.batch.id -} - -################################ -# Batch -> VPC Endpoints (HTTPS) -################################ - -resource "aws_security_group_rule" "batch_to_endpoints" { - type = "egress" - description = "HTTPS to VPC endpoints" - from_port = 443 - to_port = 443 - protocol = "tcp" - security_group_id = aws_security_group.batch.id - source_security_group_id = aws_security_group.endpoints.id -} - -################################ -# Endpoint Security Group -################################ - -resource "aws_security_group" "endpoints" { - name_prefix = "${var.project_name}-endpoint-sg" - description = "Security group for VPC endpoints" - vpc_id = var.vpc_id - - tags = merge(var.tags, { - Name = "${var.project_name}-endpoint-sg" - }) -} - -################################ -# Endpoint <- Batch (HTTPS) -################################ - -resource "aws_security_group_rule" "endpoint_ingress_batch" { - type = "ingress" - description = "HTTPS from Batch compute" - from_port = 443 - to_port = 443 - protocol = "tcp" - security_group_id = aws_security_group.endpoints.id - source_security_group_id = aws_security_group.batch.id -} - -################################ -# Endpoint -> AWS Services -################################ - -resource "aws_security_group_rule" "endpoint_egress_all" { - type = "egress" - description = "Outbound to AWS services" - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - security_group_id = aws_security_group.endpoints.id -} \ No newline at end of file +################################ +# Batch Security Group +################################ + +resource "aws_security_group" "batch" { + name_prefix = "${var.project_name}-batch-sg" + description = "Security group for Batch compute" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.project_name}-batch-sg" + }) +} + +################################ +# Batch -> Internet (HTTPS) +################################ + +resource "aws_security_group_rule" "batch_https_out" { + type = "egress" + description = "HTTPS to internet" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.batch.id +} + +################################ +# Batch -> Internet (HTTP) +################################ + +resource "aws_security_group_rule" "batch_http_out" { + type = "egress" + description = "HTTP to internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.batch.id +} + +################################ +# Batch -> Internet (TCP 9000) +################################ + +resource "aws_security_group_rule" "batch_9000_out" { + type = "egress" + description = "Allow TCP 9000 to internet" + from_port = 9000 + to_port = 9000 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.batch.id +} + +################################ +# Batch -> VPC Endpoints (HTTPS) +################################ + +resource "aws_security_group_rule" "batch_to_endpoints" { + type = "egress" + description = "HTTPS to VPC endpoints" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_group_id = aws_security_group.batch.id + source_security_group_id = aws_security_group.endpoints.id +} + +################################ +# Endpoint Security Group +################################ + +resource "aws_security_group" "endpoints" { + name_prefix = "${var.project_name}-endpoint-sg" + description = "Security group for VPC endpoints" + vpc_id = var.vpc_id + + tags = merge(var.tags, { + Name = "${var.project_name}-endpoint-sg" + }) +} + +################################ +# Endpoint <- Batch (HTTPS) +################################ + +resource "aws_security_group_rule" "endpoint_ingress_batch" { + type = "ingress" + description = "HTTPS from Batch compute" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_group_id = aws_security_group.endpoints.id + source_security_group_id = aws_security_group.batch.id +} + +################################ +# Endpoint -> AWS Services +################################ + +resource "aws_security_group_rule" "endpoint_egress_all" { + type = "egress" + description = "Outbound to AWS services" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = aws_security_group.endpoints.id +} diff --git a/lifewatch_batch_platform/terraform/modules/security_groups/outputs.tf b/lifewatch_batch_platform/terraform/modules/security_groups/outputs.tf index c5bfe56..d1f5d2d 100644 --- a/lifewatch_batch_platform/terraform/modules/security_groups/outputs.tf +++ b/lifewatch_batch_platform/terraform/modules/security_groups/outputs.tf @@ -1,7 +1,7 @@ -output "batch_security_group_id" { - value = aws_security_group.batch.id -} - -output "endpoint_security_group_id" { - value = aws_security_group.endpoints.id -} \ No newline at end of file +output "batch_security_group_id" { + value = aws_security_group.batch.id +} + +output "endpoint_security_group_id" { + value = aws_security_group.endpoints.id +} diff --git a/lifewatch_batch_platform/terraform/modules/security_groups/readme.md b/lifewatch_batch_platform/terraform/modules/security_groups/readme.md index 07ef81b..54f01ee 100644 --- a/lifewatch_batch_platform/terraform/modules/security_groups/readme.md +++ b/lifewatch_batch_platform/terraform/modules/security_groups/readme.md @@ -1,28 +1,28 @@ -# Security Groups Module - -Creates security groups for: - -- AWS Batch compute environments -- VPC Interface Endpoints - -Security model: - -Batch SG -| -| HTTPS -v -Endpoint SG -| -v -AWS services - -Batch instances cannot directly access other VPC resources. - -## Usage - -module "security_groups" { -source = "../../modules/security_groups" - -project_name = "lifewatch" -vpc_id = module.vpc.vpc_id -} +# Security Groups Module + +Creates security groups for: + +- AWS Batch compute environments +- VPC Interface Endpoints + +Security model: + +Batch SG +| +| HTTPS +v +Endpoint SG +| +v +AWS services + +Batch instances cannot directly access other VPC resources. + +## Usage + +module "security_groups" { +source = "../../modules/security_groups" + +project_name = "lifewatch" +vpc_id = module.vpc.vpc_id +} diff --git a/lifewatch_batch_platform/terraform/modules/security_groups/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/security_groups/tests/test.tftest.hcl index 1b7145d..3d0a834 100644 --- a/lifewatch_batch_platform/terraform/modules/security_groups/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/security_groups/tests/test.tftest.hcl @@ -1,102 +1,102 @@ -run "security_groups" { - command = plan - - variables { - project_name = "test" - vpc_id = "vpc-123" - - tags = { - Env = "test" - } - } - - ################################ - # Security Groups Exist - ################################ - - assert { - condition = aws_security_group.batch.vpc_id == "vpc-123" - error_message = "Batch SG must be in correct VPC" - } - - assert { - condition = aws_security_group.endpoints.vpc_id == "vpc-123" - error_message = "Endpoint SG must be in correct VPC" - } - - ################################ - # Batch Egress Rules - ################################ - - assert { - condition = aws_security_group_rule.batch_https_out.from_port == 443 - error_message = "Batch HTTPS egress must use port 443" - } - - assert { - condition = aws_security_group_rule.batch_https_out.protocol == "tcp" - error_message = "Batch HTTPS must use TCP" - } - - assert { - condition = aws_security_group_rule.batch_http_out.from_port == 80 - error_message = "Batch HTTP egress must use port 80" - } - - assert { - condition = aws_security_group_rule.batch_9000_out.from_port == 9000 - error_message = "Batch TCP 9000 egress missing" - } - - ################################ - # Batch -> Endpoint Rule - ################################ - - assert { - condition = aws_security_group_rule.batch_to_endpoints.type == "egress" - error_message = "Batch to endpoint must be egress" - } - - assert { - condition = aws_security_group_rule.batch_to_endpoints.from_port == 443 - error_message = "Batch to endpoint must use HTTPS" - } - - assert { - condition = aws_security_group_rule.batch_to_endpoints.protocol == "tcp" - error_message = "Batch to endpoint must use TCP" - } - - ################################ - # Endpoint Ingress Rule - ################################ - - assert { - condition = aws_security_group_rule.endpoint_ingress_batch.type == "ingress" - error_message = "Endpoint rule must be ingress" - } - - assert { - condition = aws_security_group_rule.endpoint_ingress_batch.from_port == 443 - error_message = "Endpoint ingress must use HTTPS" - } - - assert { - condition = aws_security_group_rule.endpoint_ingress_batch.protocol == "tcp" - error_message = "Endpoint ingress must use TCP" - } - - ################################ - # Endpoint Egress Rule - ################################ - - assert { - condition = aws_security_group_rule.endpoint_egress_all.protocol == "-1" - error_message = "Endpoint egress must allow all protocols" - } - - assert { - condition = aws_security_group_rule.endpoint_egress_all.cidr_blocks[0] == "0.0.0.0/0" - error_message = "Endpoint must allow outbound to internet" - } -} \ No newline at end of file +run "security_groups" { + command = plan + + variables { + project_name = "test" + vpc_id = "vpc-123" + + tags = { + Env = "test" + } + } + + ################################ + # Security Groups Exist + ################################ + + assert { + condition = aws_security_group.batch.vpc_id == "vpc-123" + error_message = "Batch SG must be in correct VPC" + } + + assert { + condition = aws_security_group.endpoints.vpc_id == "vpc-123" + error_message = "Endpoint SG must be in correct VPC" + } + + ################################ + # Batch Egress Rules + ################################ + + assert { + condition = aws_security_group_rule.batch_https_out.from_port == 443 + error_message = "Batch HTTPS egress must use port 443" + } + + assert { + condition = aws_security_group_rule.batch_https_out.protocol == "tcp" + error_message = "Batch HTTPS must use TCP" + } + + assert { + condition = aws_security_group_rule.batch_http_out.from_port == 80 + error_message = "Batch HTTP egress must use port 80" + } + + assert { + condition = aws_security_group_rule.batch_9000_out.from_port == 9000 + error_message = "Batch TCP 9000 egress missing" + } + + ################################ + # Batch -> Endpoint Rule + ################################ + + assert { + condition = aws_security_group_rule.batch_to_endpoints.type == "egress" + error_message = "Batch to endpoint must be egress" + } + + assert { + condition = aws_security_group_rule.batch_to_endpoints.from_port == 443 + error_message = "Batch to endpoint must use HTTPS" + } + + assert { + condition = aws_security_group_rule.batch_to_endpoints.protocol == "tcp" + error_message = "Batch to endpoint must use TCP" + } + + ################################ + # Endpoint Ingress Rule + ################################ + + assert { + condition = aws_security_group_rule.endpoint_ingress_batch.type == "ingress" + error_message = "Endpoint rule must be ingress" + } + + assert { + condition = aws_security_group_rule.endpoint_ingress_batch.from_port == 443 + error_message = "Endpoint ingress must use HTTPS" + } + + assert { + condition = aws_security_group_rule.endpoint_ingress_batch.protocol == "tcp" + error_message = "Endpoint ingress must use TCP" + } + + ################################ + # Endpoint Egress Rule + ################################ + + assert { + condition = aws_security_group_rule.endpoint_egress_all.protocol == "-1" + error_message = "Endpoint egress must allow all protocols" + } + + assert { + condition = aws_security_group_rule.endpoint_egress_all.cidr_blocks[0] == "0.0.0.0/0" + error_message = "Endpoint must allow outbound to internet" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/security_groups/variables.tf b/lifewatch_batch_platform/terraform/modules/security_groups/variables.tf index def6d30..361fb58 100644 --- a/lifewatch_batch_platform/terraform/modules/security_groups/variables.tf +++ b/lifewatch_batch_platform/terraform/modules/security_groups/variables.tf @@ -1,12 +1,12 @@ -variable "project_name" { - type = string -} - -variable "vpc_id" { - type = string -} - -variable "tags" { - type = map(string) - default = {} -} \ No newline at end of file +variable "project_name" { + type = string +} + +variable "vpc_id" { + type = string +} + +variable "tags" { + type = map(string) + default = {} +} diff --git a/lifewatch_batch_platform/terraform/modules/vpc/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/vpc/tests/test.tftest.hcl index 0d97058..08483f6 100644 --- a/lifewatch_batch_platform/terraform/modules/vpc/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/vpc/tests/test.tftest.hcl @@ -1,79 +1,79 @@ -run "vpc" { - command = plan - - variables { - project_name = "lifewatch" - region = "eu-west-1" - vpc_cidr = "10.0.0.0/16" - public_subnet_a_cidr = "10.0.103.0/24" - public_subnet_b_cidr = "10.0.102.0/24" - private_subnet_a_cidr = "10.0.1.0/24" - private_subnet_b_cidr = "10.0.2.0/24" - internet_cidr = "0.0.0.0/0" - - tags = { - Environment = "dev" - Project = "lifewatch" - ManagedBy = "terraform" - } - } - - ################################ - # VPC - ################################ - assert { - condition = aws_vpc.main.cidr_block == "10.0.0.0/16" - error_message = "VPC must have correct CIDR block" - } - - assert { - condition = aws_vpc.main.enable_dns_hostnames == true - error_message = "DNS hostnames must be enabled" - } - - assert { - condition = aws_vpc.main.enable_dns_support == true - error_message = "DNS support must be enabled" - } - - ################################ - # Public Subnets - ################################ - assert { - condition = aws_subnet.public_a.cidr_block == "10.0.103.0/24" - error_message = "Public subnet A CIDR mismatch" - } - - assert { - condition = aws_subnet.public_a.map_public_ip_on_launch == true - error_message = "Public subnet A must assign public IPs" - } - - assert { - condition = aws_subnet.public_b.cidr_block == "10.0.102.0/24" - error_message = "Public subnet B CIDR mismatch" - } - - assert { - condition = aws_subnet.public_b.map_public_ip_on_launch == true - error_message = "Public subnet B must assign public IPs" - } - - ################################ - # Private Subnets - ################################ - assert { - condition = aws_subnet.private_a.cidr_block == "10.0.1.0/24" - error_message = "Private subnet A CIDR mismatch" - } - - assert { - condition = aws_subnet.private_b.cidr_block == "10.0.2.0/24" - error_message = "Private subnet B CIDR mismatch" - } - - assert { - condition = aws_internet_gateway.igw.tags["Name"] != "" - error_message = "Internet Gateway must have a Name tag" - } -} \ No newline at end of file +run "vpc" { + command = plan + + variables { + project_name = "lifewatch" + region = "eu-west-1" + vpc_cidr = "10.0.0.0/16" + public_subnet_a_cidr = "10.0.103.0/24" + public_subnet_b_cidr = "10.0.102.0/24" + private_subnet_a_cidr = "10.0.1.0/24" + private_subnet_b_cidr = "10.0.2.0/24" + internet_cidr = "0.0.0.0/0" + + tags = { + Environment = "dev" + Project = "lifewatch" + ManagedBy = "terraform" + } + } + + ################################ + # VPC + ################################ + assert { + condition = aws_vpc.main.cidr_block == "10.0.0.0/16" + error_message = "VPC must have correct CIDR block" + } + + assert { + condition = aws_vpc.main.enable_dns_hostnames == true + error_message = "DNS hostnames must be enabled" + } + + assert { + condition = aws_vpc.main.enable_dns_support == true + error_message = "DNS support must be enabled" + } + + ################################ + # Public Subnets + ################################ + assert { + condition = aws_subnet.public_a.cidr_block == "10.0.103.0/24" + error_message = "Public subnet A CIDR mismatch" + } + + assert { + condition = aws_subnet.public_a.map_public_ip_on_launch == true + error_message = "Public subnet A must assign public IPs" + } + + assert { + condition = aws_subnet.public_b.cidr_block == "10.0.102.0/24" + error_message = "Public subnet B CIDR mismatch" + } + + assert { + condition = aws_subnet.public_b.map_public_ip_on_launch == true + error_message = "Public subnet B must assign public IPs" + } + + ################################ + # Private Subnets + ################################ + assert { + condition = aws_subnet.private_a.cidr_block == "10.0.1.0/24" + error_message = "Private subnet A CIDR mismatch" + } + + assert { + condition = aws_subnet.private_b.cidr_block == "10.0.2.0/24" + error_message = "Private subnet B CIDR mismatch" + } + + assert { + condition = aws_internet_gateway.igw.tags["Name"] != "" + error_message = "Internet Gateway must have a Name tag" + } +} diff --git a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/main.tf b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/main.tf index 7ee3b4e..d3bd0cd 100644 --- a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/main.tf +++ b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/main.tf @@ -12,4 +12,4 @@ resource "aws_vpc_endpoint" "s3" { tags = merge(var.tags, { Name = "${var.project_name}-s3-endpoint" }) -} \ No newline at end of file +} diff --git a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/outputs.tf b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/outputs.tf index 2b57790..9e3e833 100644 --- a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/outputs.tf +++ b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/outputs.tf @@ -1,4 +1,4 @@ output "s3_endpoint_id" { description = "ID of the S3 gateway endpoint." value = aws_vpc_endpoint.s3.id -} \ No newline at end of file +} diff --git a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/tests/test.tftest.hcl b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/tests/test.tftest.hcl index 3d75fac..f398b30 100644 --- a/lifewatch_batch_platform/terraform/modules/vpc_endpoints/tests/test.tftest.hcl +++ b/lifewatch_batch_platform/terraform/modules/vpc_endpoints/tests/test.tftest.hcl @@ -1,22 +1,22 @@ -run "vpc_endpoints" { - command = plan - - variables { - project_name = "test" - region = "eu-west-1" - vpc_id = "vpc-123" - private_route_table_id = "rtb-123" - private_subnet_ids = ["subnet-1", "subnet-2"] - endpoint_security_group = "sg-123" - - tags = { - Env = "test" - } - } - - # S3 endpoint type - assert { - condition = aws_vpc_endpoint.s3.vpc_endpoint_type == "Gateway" - error_message = "S3 must be gateway endpoint" - } -} \ No newline at end of file +run "vpc_endpoints" { + command = plan + + variables { + project_name = "test" + region = "eu-west-1" + vpc_id = "vpc-123" + private_route_table_id = "rtb-123" + private_subnet_ids = ["subnet-1", "subnet-2"] + endpoint_security_group = "sg-123" + + tags = { + Env = "test" + } + } + + # S3 endpoint type + assert { + condition = aws_vpc_endpoint.s3.vpc_endpoint_type == "Gateway" + error_message = "S3 must be gateway endpoint" + } +} diff --git a/readme.md b/readme.md index d7de953..dca5a42 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ dev-ops/ You will need to do this every time you want to develop locally. ``` cd server -docker compose up --build -d +docker compose up --build -d ``` This will spin up the following server components: - PostgreSQL Database # Stand-in for RDS, not used in production @@ -95,6 +95,26 @@ docker compose exec web python manage.py test A GitHub Actions workflow is located at `.github/workflows/ci.yaml` and runs automatically on push or can be triggered manually. It builds everything as described above, uses a cache to speed up things, and runs a GET to the login screen to verify things are running. +## Pre-commit Hooks + +This repository includes a `.pre-commit-config.yaml` with fast checks for: +- file hygiene (whitespace, EOF, YAML/JSON/TOML) +- Python lint/format for worker and backend Lambda/client scripts +- Terraform format + validation on changed Terraform directories +- frontend ESLint for TypeScript/React sources +- notebook output stripping under `demo_input` + +Install and enable hooks: +```bash +pip install pre-commit +pre-commit install +``` + +Run all hooks manually: +```bash +pre-commit run --all-files +``` + ## AWS Deployment with Zappa The Django server is deployed to AWS Lambda using Zappa. To keep secrets out of version control, we use a template for the Zappa configuration and inject environment variables during deployment. @@ -132,4 +152,4 @@ zappa deploy dev # OR, update an existing deployment zappa update dev -``` \ No newline at end of file +``` diff --git a/terraform-bootstrap/.terraform.lock.hcl b/terraform-bootstrap/.terraform.lock.hcl index e83c1de..2c6a4a2 100644 --- a/terraform-bootstrap/.terraform.lock.hcl +++ b/terraform-bootstrap/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = "6.34.0" hashes = [ "h1:Qzr5C24XLiHmkJVuao/Kb+jFLPaxGE/D5GUgko5VdWg=", + "h1:ygrkD6M8B4h1o976NBzNLr5VFIdXVqaLZoG03NbBDEI=", "zh:1e49dc96bf50633583e3cbe23bb357642e7e9afe135f54e061e26af6310e50d2", "zh:45651bb4dad681f17782d99d9324de182a7bb9fbe9dd22f120fdb7fe42969cc9", "zh:5880c306a427128124585b460c53bbcab9fb3767f26f796eae204f65f111a927", diff --git a/terraform-bootstrap/bucket.tf b/terraform-bootstrap/bucket.tf index ca39702..157a260 100644 --- a/terraform-bootstrap/bucket.tf +++ b/terraform-bootstrap/bucket.tf @@ -1,26 +1,26 @@ -resource "aws_s3_bucket" "tf_state" { - bucket = var.bucket_name - - tags = { - Name = "lifewatch-TerraformState" - Environment = "bootstrap" - } -} - -resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state_encryption" { - bucket = aws_s3_bucket.tf_state.bucket - - rule { - apply_server_side_encryption_by_default { - sse_algorithm = "AES256" - } - } -} - -resource "aws_s3_bucket_versioning" "terraform_state_versioning" { - bucket = aws_s3_bucket.tf_state.id - - versioning_configuration { - status = "Enabled" - } -} \ No newline at end of file +resource "aws_s3_bucket" "tf_state" { + bucket = var.bucket_name + + tags = { + Name = "lifewatch-TerraformState" + Environment = "bootstrap" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state_encryption" { + bucket = aws_s3_bucket.tf_state.bucket + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_versioning" "terraform_state_versioning" { + bucket = aws_s3_bucket.tf_state.id + + versioning_configuration { + status = "Enabled" + } +} diff --git a/terraform-bootstrap/bucket_policy.tf b/terraform-bootstrap/bucket_policy.tf index 10bab92..a5a031b 100644 --- a/terraform-bootstrap/bucket_policy.tf +++ b/terraform-bootstrap/bucket_policy.tf @@ -1,35 +1,35 @@ -resource "aws_s3_bucket_policy" "tf_state_policy" { - bucket = aws_s3_bucket.tf_state.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Principal = { - AWS = var.terraform_users - } - Action = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ] - Resource = [ - "${aws_s3_bucket.tf_state.arn}/*" - ] - }, - { - Effect = "Allow" - Principal = { - AWS = var.terraform_users - } - Action = [ - "s3:ListBucket" - ] - Resource = [ - aws_s3_bucket.tf_state.arn - ] - } - ] - }) -} \ No newline at end of file +resource "aws_s3_bucket_policy" "tf_state_policy" { + bucket = aws_s3_bucket.tf_state.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + AWS = var.terraform_users + } + Action = [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject" + ] + Resource = [ + "${aws_s3_bucket.tf_state.arn}/*" + ] + }, + { + Effect = "Allow" + Principal = { + AWS = var.terraform_users + } + Action = [ + "s3:ListBucket" + ] + Resource = [ + aws_s3_bucket.tf_state.arn + ] + } + ] + }) +} diff --git a/terraform-bootstrap/dynamodb.tf b/terraform-bootstrap/dynamodb.tf index 54cd986..6eba59e 100644 --- a/terraform-bootstrap/dynamodb.tf +++ b/terraform-bootstrap/dynamodb.tf @@ -1,15 +1,15 @@ -resource "aws_dynamodb_table" "tf_locks" { - name = var.dynamodb_table_name - billing_mode = "PAY_PER_REQUEST" - hash_key = "LockID" - - attribute { - name = "LockID" - type = "S" - } - - tags = { - Name = "lifewatch-TerraformLocks" - Environment = "bootstrap" - } -} \ No newline at end of file +resource "aws_dynamodb_table" "tf_locks" { + name = var.dynamodb_table_name + billing_mode = "PAY_PER_REQUEST" + hash_key = "LockID" + + attribute { + name = "LockID" + type = "S" + } + + tags = { + Name = "lifewatch-TerraformLocks" + Environment = "bootstrap" + } +} diff --git a/terraform-bootstrap/main.tf b/terraform-bootstrap/main.tf index 35eb4a5..a569e17 100644 --- a/terraform-bootstrap/main.tf +++ b/terraform-bootstrap/main.tf @@ -1,9 +1,9 @@ -terraform { - required_providers { - aws = { - source = "hashicorp/aws" - version = "6.34.0" - } - } - required_version = ">= 1.5" -} \ No newline at end of file +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "6.34.0" + } + } + required_version = ">= 1.5" +} diff --git a/terraform-bootstrap/provider.tf b/terraform-bootstrap/provider.tf index 245523c..be8bcca 100644 --- a/terraform-bootstrap/provider.tf +++ b/terraform-bootstrap/provider.tf @@ -1,4 +1,4 @@ - -provider "aws" { - region = "eu-west-1" -} + +provider "aws" { + region = "eu-west-1" +} diff --git a/terraform-bootstrap/readme.md b/terraform-bootstrap/readme.md index 714c8eb..02289a1 100644 --- a/terraform-bootstrap/readme.md +++ b/terraform-bootstrap/readme.md @@ -1,114 +1,114 @@ ---- - -# Terraform S3 Backend & DynamoDB Lock Setup - -This repository contains the Terraform bootstrap configuration for setting up a remote backend for Terraform using S3 and DynamoDB locking. - -It ensures that: - -- Terraform state is stored remotely in a versioned and encrypted S3 bucket. -- Concurrent Terraform runs are prevented using a DynamoDB table for state locking. -- Only authorized IAM users can access and modify Terraform state. - ---- - -## Components - -### 1. S3 Bucket - -- Stores Terraform state files (`.tfstate`). -- Features enabled: - - Versioning – protects against accidental deletion. - - Server-side encryption (AES256) – encrypts state at rest. - -- Access is restricted via bucket policy to specific IAM users. - -### 2. DynamoDB Table - -- Used for state locking, preventing multiple users or CI jobs from modifying the state simultaneously. -- Table has a single primary key (`LockID`). -- Terraform automatically creates and deletes lock entries during `apply`. - -### 3. IAM Users & Permissions - -Terraform requires specific permissions to interact with the S3 bucket and DynamoDB lock table. Assign the following IAM policy to all users who need access to Terraform state: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["s3:ListBucket"], - "Resource": "arn:aws:s3:::lifewatch-terraform-state-eu-west-1" - }, - { - "Effect": "Allow", - "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], - "Resource": "arn:aws:s3:::lifewatch-terraform-state-eu-west-1/*" - }, - { - "Effect": "Allow", - "Action": [ - "dynamodb:GetItem", - "dynamodb:PutItem", - "dynamodb:DeleteItem", - "dynamodb:UpdateItem" - ], - "Resource": "arn:aws:dynamodb:eu-west-1:020858641931:table/terraform-locks" - } - ] -} -``` - -> This policy is based on the guide by Deepesh Jaiswal: [Setting up Terraform with S3 Backend and DynamoDB Locking](https://medium.com/@deepeshjaiswal6734/setting-up-terraform-with-s3-backend-and-dynamodb-locking-1e4b69e0b3cd) - -Important: Assign this policy to all IAM users who will run Terraform against this backend. - ---- - -## Usage - -1. Initialize and apply the bootstrap - -```bash -cd bootstrap -terraform init -terraform apply -``` - -- This creates the S3 bucket, DynamoDB table, and applies the bucket policy to allow your IAM users to access the state. - -2. Configure your main Terraform project - -We have added a backend block pointing to the S3 bucket and DynamoDB lock table: - -```hcl -terraform { - backend "s3" { - bucket = "lifewatch-terraform-state-eu-west-1" - key = "project/dev/terraform.tfstate" - region = "eu-west-1" - dynamodb_table = "terraform-locks" - encrypt = true - } -} -``` - -3. Run Terraform commands normally - -```bash -terraform init -terraform plan -terraform apply -``` - -- Terraform will automatically create and update the state in S3 and handle locks in DynamoDB. - ---- - -## Notes & Best Practices - -- Do not share the bucket with untrusted users; Terraform state may contain sensitive information (passwords, secrets, ARNs). -- Use different keys per environment (`dev`, `staging`, `prod`) in the same bucket for separation. -- Enable DynamoDB locking to prevent simultaneous Terraform runs. +--- + +# Terraform S3 Backend & DynamoDB Lock Setup + +This repository contains the Terraform bootstrap configuration for setting up a remote backend for Terraform using S3 and DynamoDB locking. + +It ensures that: + +- Terraform state is stored remotely in a versioned and encrypted S3 bucket. +- Concurrent Terraform runs are prevented using a DynamoDB table for state locking. +- Only authorized IAM users can access and modify Terraform state. + +--- + +## Components + +### 1. S3 Bucket + +- Stores Terraform state files (`.tfstate`). +- Features enabled: + - Versioning – protects against accidental deletion. + - Server-side encryption (AES256) – encrypts state at rest. + +- Access is restricted via bucket policy to specific IAM users. + +### 2. DynamoDB Table + +- Used for state locking, preventing multiple users or CI jobs from modifying the state simultaneously. +- Table has a single primary key (`LockID`). +- Terraform automatically creates and deletes lock entries during `apply`. + +### 3. IAM Users & Permissions + +Terraform requires specific permissions to interact with the S3 bucket and DynamoDB lock table. Assign the following IAM policy to all users who need access to Terraform state: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": "arn:aws:s3:::lifewatch-terraform-state-eu-west-1" + }, + { + "Effect": "Allow", + "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], + "Resource": "arn:aws:s3:::lifewatch-terraform-state-eu-west-1/*" + }, + { + "Effect": "Allow", + "Action": [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:DeleteItem", + "dynamodb:UpdateItem" + ], + "Resource": "arn:aws:dynamodb:eu-west-1:020858641931:table/terraform-locks" + } + ] +} +``` + +> This policy is based on the guide by Deepesh Jaiswal: [Setting up Terraform with S3 Backend and DynamoDB Locking](https://medium.com/@deepeshjaiswal6734/setting-up-terraform-with-s3-backend-and-dynamodb-locking-1e4b69e0b3cd) + +Important: Assign this policy to all IAM users who will run Terraform against this backend. + +--- + +## Usage + +1. Initialize and apply the bootstrap + +```bash +cd bootstrap +terraform init +terraform apply +``` + +- This creates the S3 bucket, DynamoDB table, and applies the bucket policy to allow your IAM users to access the state. + +2. Configure your main Terraform project + +We have added a backend block pointing to the S3 bucket and DynamoDB lock table: + +```hcl +terraform { + backend "s3" { + bucket = "lifewatch-terraform-state-eu-west-1" + key = "project/dev/terraform.tfstate" + region = "eu-west-1" + dynamodb_table = "terraform-locks" + encrypt = true + } +} +``` + +3. Run Terraform commands normally + +```bash +terraform init +terraform plan +terraform apply +``` + +- Terraform will automatically create and update the state in S3 and handle locks in DynamoDB. + +--- + +## Notes & Best Practices + +- Do not share the bucket with untrusted users; Terraform state may contain sensitive information (passwords, secrets, ARNs). +- Use different keys per environment (`dev`, `staging`, `prod`) in the same bucket for separation. +- Enable DynamoDB locking to prevent simultaneous Terraform runs. diff --git a/terraform-bootstrap/variables.tf b/terraform-bootstrap/variables.tf index 063a511..248f26e 100644 --- a/terraform-bootstrap/variables.tf +++ b/terraform-bootstrap/variables.tf @@ -1,28 +1,28 @@ -variable "aws_region" { - description = "AWS region for the S3 bucket and DynamoDB table" - type = string - default = "eu-west-1" -} - -variable "terraform_users" { - description = "List of IAM users who can access the Terraform state bucket" - type = list(string) - default = [ - "arn:aws:iam::020858641931:user/KayleDevOps", - "arn:aws:iam::020858641931:user/Prathik", - "arn:aws:iam::020858641931:user/Giorgos", - "arn:aws:iam::020858641931:user/Eneko" - ] -} - -variable "bucket_name" { - description = "Name of the S3 bucket to store Terraform state (must be globally unique)" - type = string - default = "lifewatch-terraform-state-eu-west-1" -} - -variable "dynamodb_table_name" { - description = "DynamoDB table name for Terraform state locking" - type = string - default = "lifewatch-terraform-locks" -} +variable "aws_region" { + description = "AWS region for the S3 bucket and DynamoDB table" + type = string + default = "eu-west-1" +} + +variable "terraform_users" { + description = "List of IAM users who can access the Terraform state bucket" + type = list(string) + default = [ + "arn:aws:iam::020858641931:user/KayleDevOps", + "arn:aws:iam::020858641931:user/Prathik", + "arn:aws:iam::020858641931:user/Giorgos", + "arn:aws:iam::020858641931:user/Eneko" + ] +} + +variable "bucket_name" { + description = "Name of the S3 bucket to store Terraform state (must be globally unique)" + type = string + default = "lifewatch-terraform-state-eu-west-1" +} + +variable "dynamodb_table_name" { + description = "DynamoDB table name for Terraform state locking" + type = string + default = "lifewatch-terraform-locks" +} diff --git a/worker/Dockerfile b/worker/Dockerfile index 409794a..fddd33c 100644 --- a/worker/Dockerfile +++ b/worker/Dockerfile @@ -9,4 +9,4 @@ COPY . /app RUN chown -R ${NB_UID} /app USER ${NB_UID} -CMD ["python", "worker.py"] \ No newline at end of file +CMD ["python", "worker.py"] diff --git a/worker/worker.py b/worker/worker.py index d3d402b..5d68a9f 100644 --- a/worker/worker.py +++ b/worker/worker.py @@ -7,23 +7,27 @@ import papermill as pm import botocore + def download_s3_folder(s3_client, bucket, prefix, local_dir="."): """Downloads all files from a specific S3 prefix to a local directory.""" - paginator = s3_client.get_paginator('list_objects_v2') + paginator = s3_client.get_paginator("list_objects_v2") for page in paginator.paginate(Bucket=bucket, Prefix=prefix): - for obj in page.get('Contents', []): - s3_key = obj['Key'] + for obj in page.get("Contents", []): + s3_key = obj["Key"] filename = os.path.basename(s3_key) - + # Skip empty directory markers if not filename: continue - + local_path = os.path.join(local_dir, filename) print(f"Downloading s3://{bucket}/{s3_key} to {local_path}...") s3_client.download_file(bucket, s3_key, local_path) -def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_filename="outputs"): + +def zip_and_upload_folder( + s3_client, bucket, prefix, local_dir="./outputs", zip_filename="outputs" +): """Zips a local directory and uploads it to S3 as a single archive.""" if not os.path.exists(local_dir) or not os.listdir(local_dir): print(f"Directory {local_dir} does not exist or is empty. Skipping upload.") @@ -31,14 +35,15 @@ def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_ print(f"Zipping {local_dir} into {zip_filename}.zip...") # shutil.make_archive creates the zip file in the current working directory - shutil.make_archive(zip_filename, 'zip', local_dir) - + shutil.make_archive(zip_filename, "zip", local_dir) + zip_path = f"{zip_filename}.zip" s3_key = f"{prefix}{zip_path}" - + print(f"Uploading {zip_path} to s3://{bucket}/{s3_key}...") s3_client.upload_file(zip_path, bucket, s3_key) + # Initialization and Environment Variable Checks s3 = boto3.client("s3") @@ -71,12 +76,12 @@ def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_ s3.download_file(bucket, f"{prefix}environment.yaml", "./environment.yaml") env_file_path = "./environment.yaml" except botocore.exceptions.ClientError as e: - if e.response['Error']['Code'] == "404": + if e.response["Error"]["Code"] == "404": try: s3.download_file(bucket, f"{prefix}environment.yml", "./environment.yml") env_file_path = "./environment.yml" except botocore.exceptions.ClientError as e2: - if e2.response['Error']['Code'] == "404": + if e2.response["Error"]["Code"] == "404": print("No environment file (.yaml or .yml) found at job root.") else: print(f"FATAL: Error checking for environment.yml: {e2}") @@ -95,36 +100,72 @@ def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_ # Determine notebook language try: - with open(input_nb_path, 'r', encoding='utf-8') as f: + with open(input_nb_path, "r", encoding="utf-8") as f: nb_data = json.load(f) - lang = nb_data.get('metadata', {}).get('language_info', {}).get('name', 'r').lower() - auto_kernel = 'python3' if lang == 'python' else 'ir' -except Exception as e: - print(f"Warning: Could not detect notebook language, defaulting to 'python3'.") - lang = 'python' - auto_kernel = 'python3' + lang = nb_data.get("metadata", {}).get("language_info", {}).get("name", "r").lower() + auto_kernel = "python3" if lang == "python" else "ir" +except Exception: + print("Warning: Could not detect notebook language, defaulting to 'python3'.") + lang = "python" + auto_kernel = "python3" # Build Isolated Sandbox if Environment File Exists if env_file_path and os.path.exists(env_file_path): - print(f"Environment file ({env_file_path}) detected! Building isolated sandbox environment...") + print( + f"Environment file ({env_file_path}) detected! Building isolated sandbox environment..." + ) try: # Create new environment named 'job_env' - subprocess.run(["mamba", "env", "create", "-n", "job_env", "-f", env_file_path], check=True) - + subprocess.run( + ["mamba", "env", "create", "-n", "job_env", "-f", env_file_path], check=True + ) + # Inject kernel into isolated environment - if lang == 'python': - subprocess.run(["mamba", "install", "-n", "job_env", "ipykernel", "-y"], check=True) - subprocess.run(["conda", "run", "-n", "job_env", "python", "-m", "ipykernel", "install", "--user", "--name", "job_env"], check=True) + if lang == "python": + subprocess.run( + ["mamba", "install", "-n", "job_env", "ipykernel", "-y"], check=True + ) + subprocess.run( + [ + "conda", + "run", + "-n", + "job_env", + "python", + "-m", + "ipykernel", + "install", + "--user", + "--name", + "job_env", + ], + check=True, + ) else: - subprocess.run(["mamba", "install", "-n", "job_env", "r-irkernel", "-y"], check=True) - subprocess.run(["conda", "run", "-n", "job_env", "Rscript", "-e", "IRkernel::installspec(name='job_env', user=TRUE)"], check=True) - + subprocess.run( + ["mamba", "install", "-n", "job_env", "r-irkernel", "-y"], check=True + ) + subprocess.run( + [ + "conda", + "run", + "-n", + "job_env", + "Rscript", + "-e", + "IRkernel::installspec(name='job_env', user=TRUE)", + ], + check=True, + ) + # Override the Papermill kernel - auto_kernel = 'job_env' + auto_kernel = "job_env" print(f"Isolated sandbox built and registered as kernel: {auto_kernel}") - + except subprocess.CalledProcessError as e: - print(f"FATAL: Failed to build isolated environment. Error code: {e.returncode}") + print( + f"FATAL: Failed to build isolated environment. Error code: {e.returncode}" + ) sys.exit(1) else: print("Using default container tools (No custom environment built).") @@ -134,15 +175,12 @@ def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_ try: print(f"Executing notebook using kernel: {auto_kernel}...") pm.execute_notebook( - input_nb_path, - output_nb_path, - kernel_name=auto_kernel, - log_output=True + input_nb_path, output_nb_path, kernel_name=auto_kernel, log_output=True ) print("Execution Successful!") except pm.PapermillExecutionError as e: print(f"FATAL: Notebook Logic Error in cell {e.exec_count}: {e}") - + # Upload the partially executed notebook anyway for debugging s3.upload_file(output_nb_path, bucket, f"{prefix}failed_output.ipynb") # Attempt to zip and upload whatever outputs were generated before the crash @@ -158,14 +196,14 @@ def zip_and_upload_folder(s3_client, bucket, prefix, local_dir="./outputs", zip_ output_key = f"{prefix}output.ipynb" print(f"Uploading executed notebook to s3://{bucket}/{output_key}...") s3.upload_file(output_nb_path, bucket, output_key) - + # Zip and upload everything in the local ./outputs directory print("Zipping and uploading generated data files...") zip_and_upload_folder(s3, bucket, prefix, "./outputs", "outputs") - + print("Upload complete. Container exiting cleanly.") except Exception as e: print(f"FATAL: Failed to upload outputs: {e}") sys.exit(1) -sys.exit(0) \ No newline at end of file +sys.exit(0)