diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 90b080ddb1..68cd00e034 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -44,20 +44,13 @@ // Set *default* container specific settings.json values on container create. "settings": { "terminal.integrated.defaultProfile.linux": "bash", + "editor.formatOnPaste": true, + "editor.formatOnSave": true, "python.pythonPath": "/usr/local/bin/python", - "python.linting.enabled": true, - "python.linting.pylintEnabled": false, - "python.linting.flake8Enabled": true, "python.formatting.provider": "black", "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", - "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", - "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", - "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", - "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", - "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", - "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "python.testing.pytestArgs": [ @@ -275,6 +268,8 @@ "extensions": [ "ms-python.python", "ms-python.pylance", + "ms-python.flake8", + "nwgh.bandit", "hashicorp.terraform", "github.vscode-pull-request-github", "gitHub.copilot", @@ -294,5 +289,7 @@ ], // Run commands after the container is created. "postCreateCommand": "./.devcontainer/scripts/post-create.sh", - "initializeCommand": ["./.devcontainer/scripts/initialize"] + "initializeCommand": [ + "./.devcontainer/scripts/initialize" + ] } diff --git a/.github/actions/devcontainer_run_command/action.yml b/.github/actions/devcontainer_run_command/action.yml index 55157e1716..ba790c563c 100644 --- a/.github/actions/devcontainer_run_command/action.yml +++ b/.github/actions/devcontainer_run_command/action.yml @@ -178,9 +178,19 @@ runs: - name: Run command in DevContainer shell: bash run: | + # Write command to a command.sh script file + cat <> ./command.sh + #!/bin/bash + set -x + ${{ inputs.COMMAND }} + EOF + + chmod +x ./command.sh + docker run --rm --mount \ "type=bind,src=${{ github.workspace }},dst=/workspaces/tre" \ -v /var/run/docker.sock:/var/run/docker.sock \ + -v "./command.sh:/workspaces/tre/command.sh" \ --workdir /workspaces/tre \ --user vscode \ -e TF_INPUT="0" \ @@ -246,4 +256,7 @@ runs: -e TF_VAR_app_gateway_sku=${{ inputs.APP_GATEWAY_SKU }} \ -e E2E_TESTS_NUMBER_PROCESSES="${{ inputs.E2E_TESTS_NUMBER_PROCESSES }}" \ '${{ inputs.CI_CACHE_ACR_NAME }}${{ env.ACR_DOMAIN_SUFFIX }}/tredev:${{ inputs.DEVCONTAINER_TAG }}' \ - bash -c "${{ inputs.COMMAND }}" + bash -c -x "./command.sh" + + # Clean up temporary script file + rm ./command.sh diff --git a/.github/workflows/deploy_tre_reusable.yml b/.github/workflows/deploy_tre_reusable.yml index 99b9c7e4d0..3942f9c4cb 100644 --- a/.github/workflows/deploy_tre_reusable.yml +++ b/.github/workflows/deploy_tre_reusable.yml @@ -424,9 +424,14 @@ jobs: uses: ./.github/actions/devcontainer_run_command with: # Although porter publish will build automatically, our makefile build target includes logic that should run - COMMAND: >- - for i in {1..3}; do make bundle-build bundle-publish DIR=${{ matrix.BUNDLE_DIR }} - && ec=0 && break || ec=\$? && sleep 30; done; (exit \$ec) + COMMAND: | + # Loop to retry the make command up to 3 times + for i in {1..3}; do + make bundle-build bundle-publish DIR=${{ matrix.BUNDLE_DIR }} && ec=0 && break || ec=\$? && sleep 30; + done; + + # Exit with the last status code + (exit \$ec) DEVCONTAINER_TAG: ${{ inputs.DEVCONTAINER_TAG }} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} AZURE_ENVIRONMENT: ${{ vars.AZURE_ENVIRONMENT }} @@ -470,9 +475,14 @@ jobs: uses: ./.github/actions/devcontainer_run_command with: # Although porter publish will build automatically, our makefile build target includes logic that should run - COMMAND: >- - for i in {1..3}; do make bundle-build bundle-publish DIR=${{ matrix.BUNDLE_DIR }} - && ec=0 && break || ec=\$? && sleep 30; done; (exit \$ec) + COMMAND: | + # Loop to retry the make command up to 3 times + for i in {1..3}; do + make bundle-build bundle-publish DIR=${{ matrix.BUNDLE_DIR }} && ec=0 && break || ec=\$? && sleep 30; + done + + # Exit with the last status code + (exit \$ec) DEVCONTAINER_TAG: ${{ inputs.DEVCONTAINER_TAG }} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} AZURE_ENVIRONMENT: ${{ vars.AZURE_ENVIRONMENT }} @@ -514,9 +524,14 @@ jobs: - name: Register bundle uses: ./.github/actions/devcontainer_run_command with: - COMMAND: >- - for i in {1..3}; do make bundle-register DIR=${{ matrix.BUNDLE_DIR }} - && ec=0 && break || ec=\$? && sleep 10; done; (exit \$ec) + COMMAND: | + # Loop to retry the make command up to 3 times + for i in {1..3}; do + make bundle-register DIR=${{ matrix.BUNDLE_DIR }} && ec=0 && break || ec=\$? && sleep 10; + done + + # Exit with the last status code + (exit \$ec) DEVCONTAINER_TAG: ${{ inputs.DEVCONTAINER_TAG }} CI_CACHE_ACR_NAME: ${{ secrets.CI_CACHE_ACR_NAME}} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} @@ -574,9 +589,14 @@ jobs: - name: Register bundle uses: ./.github/actions/devcontainer_run_command with: - COMMAND: >- - for i in {1..3}; do make bundle-register DIR=${{ matrix.BUNDLE_DIR }} - && ec=0 && break || ec=\$? && sleep 10; done; (exit \$ec) + COMMAND: | + # Loop to retry the make command up to 3 times + for i in {1..3}; do + make bundle-register DIR=${{ matrix.BUNDLE_DIR }} && ec=0 && break || ec=\$? && sleep 10; + done; + + # Exit with the last status code + (exit \$ec) DEVCONTAINER_TAG: ${{ inputs.DEVCONTAINER_TAG }} CI_CACHE_ACR_NAME: ${{ secrets.CI_CACHE_ACR_NAME}} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} @@ -623,9 +643,14 @@ jobs: - name: Register bundle uses: ./.github/actions/devcontainer_run_command with: - COMMAND: >- - for i in {1..3}; do make bundle-register DIR=${{ matrix.BUNDLE_DIR }} - && ec=0 && break || ec=\$? && sleep 10; done; (exit \$ec) + COMMAND: | + # Loop to retry the make command up to 3 times + for i in {1..3}; do + make bundle-register DIR=${{ matrix.BUNDLE_DIR }} && ec=0 && break || ec=\$? && sleep 10; + done; + + # Exit with the last status code + (exit \$ec) DEVCONTAINER_TAG: ${{ inputs.DEVCONTAINER_TAG }} CI_CACHE_ACR_NAME: ${{ secrets.CI_CACHE_ACR_NAME}} AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c41d6f0ae..c596b48c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,33 @@ -## 0.20.0 (Unreleased) +## 0.21.0 (Unreleased) **BREAKING CHANGES & MIGRATIONS**: -* InnerEye and MLFlow bundles depreciated and removed from main. If you wish to update and deploy these worksapce services they can be retrieved from release 0.19.1. ([#4127](https://github.com/microsoft/AzureTRE/issues/4127)) -* This released removed support for Porter v0.*. If you're upgrading from a much earlier verion you can't go directly to this one. ([#4228](https://github.com/microsoft/AzureTRE/issues/4228)) + +ENHANCEMENTS: +* Core key vault firewall should not be set to "Allow public access from all networks" ([#4250](https://github.com/microsoft/AzureTRE/issues/4250)) +* Allow workspace App Service Plan SKU to be updated ([#4331](https://github.com/microsoft/AzureTRE/issues/4331)) +* Add core requests endpoint and UI to enable requests to be managed TRE wide. ([[#2510](https://github.com/microsoft/AzureTRE/issues/2510)]) +* Remove public IP from TRE's firewall when forced tunneling is configured ([#4346](https://github.com/microsoft/AzureTRE/pull/4346)) +* Upgrade AzureRM Terraform provider from `3.117.0` to `4.14.0`. ([[#4255](https://github.com/microsoft/AzureTRE/pull/4255/)]) +* Subnet definitions are now inline in the `azurerm_virtual_network` resource, and NSG associations are set using `security_group` in each subnet block (no separate `azurerm_subnet_network_security_group_association` needed). ([[#4255](https://github.com/microsoft/AzureTRE/pull/4255/)]) +* Azure Cosmos DB should disable public network access ([#4322](https://github.com/microsoft/AzureTRE/issues/4322)) +* Add bundle target to Makefile for handling different bundle types in single command ([#4372](https://github.com/microsoft/AzureTRE/issues/4372)) +* Migrate UI to Vite build engine and update dependencies ([#4368](https://github.com/microsoft/AzureTRE/pull/4368)) +* Add Windows image field to the Admin VM template ([#4274](https://github.com/microsoft/AzureTRE/pull/4274)) +* Update TLS to the latest version for web apps / function apps (([#4351](https://github.com/microsoft/AzureTRE/issues/4351)) + +BUG FIXES: +* Fix upgrade when porter install has failed ([#4338](https://github.com/microsoft/AzureTRE/pull/4338)) +* Certs shared service: Secret nexus-ssl-password is currently in a deleted but recoverable state ([#4294](https://github.com/microsoft/AzureTRE/issues/4294)]) +* Fix Cosmos DB local debugging configuration ([#4340](https://github.com/microsoft/AzureTRE/pull/4340)) + +COMPONENTS: + +## 0.20.0 (Feburary 9, 2025) + +**BREAKING CHANGES & MIGRATIONS**: +* InnerEye and MLFlow bundles depreciated and removed from main. If you wish to update and deploy these workspace services they can be retrieved from release 0.19.1. ([#4127](https://github.com/microsoft/AzureTRE/issues/4127)) +* This release removed support for Porter v0.*. If you're upgrading from a much earlier version you can't go directly to this one. ([#4228](https://github.com/microsoft/AzureTRE/issues/4228)) FEATURES: * Add support for customer-managed keys encryption. Core support ([#4141](https://github.com/microsoft/AzureTRE/issues/4142), [#4144](https://github.com/microsoft/AzureTRE/issues/4144)), Base workspace ([#4161](https://github.com/microsoft/AzureTRE/pull/4161)), other templates ([#4145](https://github.com/microsoft/AzureTRE/issues/4145)) @@ -34,6 +58,7 @@ ENHANCEMENTS: * Update Guacamole dependencies ([[#4232](https://github.com/microsoft/AzureTRE/issues/4232)]) * Add option to force tunnel TRE's Firewall ([#4237](https://github.com/microsoft/AzureTRE/issues/4237)) * Add EventGrid diagnostics to identify airlock issues ([#4258](https://github.com/microsoft/AzureTRE/issues/4258)) +* Disable local authentication in ServiceBus ([#4259](https://github.com/microsoft/AzureTRE/issues/4259)) * Allow enablement of Secure Boot and vTPM for Guacamole VMs ([#4235](https://github.com/microsoft/AzureTRE/issues/4235)) * Surface the server-layout parameter of Guacamole [server-layout](https://guacamole.apache.org/doc/gug/configuring-guacamole.html#session-settings) ([#4234](https://github.com/microsoft/AzureTRE/issues/4234)) * Add encryption at host for VMs ([#4263](https://github.com/microsoft/AzureTRE/pull/4263)) @@ -41,6 +66,7 @@ ENHANCEMENTS: * Airlock function host storage to use the user-assigned managed identity ([#4276](https://github.com/microsoft/AzureTRE/issues/4276)) * Disable local authentication in EventGrid ([#4254](https://github.com/microsoft/AzureTRE/issues/4254)) + BUG FIXES: * Update KeyVault references in API to use the version so Terraform cascades the update ([#4112](https://github.com/microsoft/AzureTRE/pull/4112)) * Template images are showing CVEs ([#4153](https://github.com/microsoft/AzureTRE/issues/4153)) @@ -53,7 +79,7 @@ BUG FIXES: * Fix failing tests, .env missing and storage logs ([#4207](https://github.com/microsoft/AzureTRE/issues/4207)) * Unable to delete virtual machines, add skip_shutdown_and_force_delete = true ([#4135](https://github.com/microsoft/AzureTRE/issues/4135)) * Bump terraform version in windows VM template ([#4212](https://github.com/microsoft/AzureTRE/issues/4212)) -* Upgrade azurerm terraform provider from v3.112.0 to v3.117.0 to mitiagte storage account deployment issue ([#4004](https://github.com/microsoft/AzureTRE/issues/4004)) +* Upgrade azurerm terraform provider from v3.112.0 to v3.117.0 to mitigate storage account deployment issue ([#4004](https://github.com/microsoft/AzureTRE/issues/4004)) * Fix VM actions where Workspace shared storage doesn't allow shared key access ([#4222](https://github.com/microsoft/AzureTRE/issues/4222)) * Fix public exposure in Guacamole service ([[#4199](https://github.com/microsoft/AzureTRE/issues/4199)]) * Fix Azure ML network tags to use name rather than ID ([[#4151](https://github.com/microsoft/AzureTRE/issues/4151)]) @@ -61,8 +87,40 @@ BUG FIXES: * Recreate tre_output.json if empty. ([[#4292](https://github.com/microsoft/AzureTRE/issues/4292)]) * Ensure R directory is present before attempting to update package mirror URL ([#4332](https://github.com/microsoft/AzureTRE/pull/4332)) + COMPONENTS: +| name | version | +| ----- | ----- | +| devops | 0.5.5 | +| core | 0.11.23 | +| ui | 0.6.3 | +| tre-shared-service-databricks-private-auth | 0.1.11 | +| tre-shared-service-gitea | 1.1.4 | +| tre-shared-service-sonatype-nexus | 3.3.2 | +| tre-shared-service-firewall | 1.3.0 | +| tre-shared-service-admin-vm | 0.5.2 | +| tre-shared-service-certs | 0.7.3 | +| tre-shared-service-airlock-notifier | 1.0.8 | +| tre-shared-service-cyclecloud | 0.7.2 | +| tre-workspace-airlock-import-review | 0.14.2 | +| tre-workspace-base | 1.9.2 | +| tre-workspace-unrestricted | 0.13.2 | +| tre-workspace-service-gitea | 1.2.2 | +| tre-workspace-service-mysql | 1.0.9 | +| tre-workspace-service-health | 0.2.11 | +| tre-workspace-service-openai | 1.0.6 | +| tre-service-azureml | 0.9.2 | +| tre-user-resource-aml-compute-instance | 0.5.11 | +| tre-service-databricks | 1.0.10 | +| tre-workspace-service-azuresql | 1.0.15 | +| tre-service-guacamole | 0.12.7 | +| tre-service-guacamole-export-reviewvm | 0.2.2 | +| tre-service-guacamole-linuxvm | 1.2.4 | +| tre-service-guacamole-import-reviewvm | 0.3.2 | +| tre-service-guacamole-windowsvm | 1.2.6 | +| tre-workspace-service-ohdsi | 0.3.2 | + ## 0.19.1 **BREAKING CHANGES & MIGRATIONS**: @@ -78,6 +136,7 @@ BUG FIXES: * Workspace creation blocked due to Azure API depreciation ([#4095](https://github.com/microsoft/AzureTRE/issues/4095)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.2 | @@ -136,6 +195,7 @@ BUG FIXES: * Update .NET version on Linux VMs ([#4067](https://github.com/microsoft/AzureTRE/issues/4067)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -199,6 +259,7 @@ BUG FIXES: * Add lifecycle rule to the Gitea Shared Service template for the MySQL resource to stop it recreating on `update` ([#4006](https://github.com/microsoft/AzureTRE/issues/4006)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -253,6 +314,7 @@ BUG FIXES: * Fix issue with firewall failing to deploy on a new TRE deploy ([#3775](https://github.com/microsoft/AzureTRE/issues/3775)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -302,6 +364,7 @@ BUG FIXES: * Airlock Import Review workspace uses dedicated DNS zone to prevent conflict with core ([#3767](https://github.com/microsoft/AzureTRE/pull/3767)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -344,6 +407,7 @@ BUG FIXES: * Fix workspace not loading fails if operation or history roles are not loaded ([#3755](https://github.com/microsoft/AzureTRE/issues/3755)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -382,6 +446,7 @@ BUG FIXES: * SecuredByRole failing if roles are null ([#3740](https://github.com/microsoft/AzureTRE/issues/3740 )) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -431,6 +496,7 @@ BUG FIXES: * Fix issue with cost tags not displaying correctly for some user roles ([#3721](https://github.com/microsoft/AzureTRE/issues/3721)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -467,6 +533,7 @@ BUG FIXES: * Fix firewall config related to Nexus so that `pypi.org` is added to the allow-list ([#3694](https://github.com/microsoft/AzureTRE/issues/3694)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -516,6 +583,7 @@ BUG FIXES: * Added missing region entries in `databricks-udr.json` ([[#3688](https://github.com/microsoft/AzureTRE/pull/3688)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -555,6 +623,7 @@ BUG FIXES: * Upgrade airlock and unrestricted workspaces to base workspace version 0.12.0 ([#3659](https://github.com/microsoft/AzureTRE/pull/3659)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -614,6 +683,7 @@ BUG FIXES: COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -655,6 +725,7 @@ BUG FIXES: * Nexus fails to install due to `az login` and firewall rules ([#3453](https://github.com/microsoft/AzureTRE/issues/3453)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.5.1 | @@ -857,6 +928,7 @@ BUG FIXES: * Fix KeyVault purge error on MLFlow uninstall ([#3082](https://github.com/microsoft/AzureTRE/pull/3082)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.4.4 | @@ -933,6 +1005,7 @@ BUG FIXES: * Handle 429 TooManyRequests and 503 ServiceUnavailable which might return from Azure Cost Management in TRE Cost API ([#2835](https://github.com/microsoft/AzureTRE/issues/2835)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.4.2 | @@ -980,6 +1053,7 @@ BUG FIXES: * Fix issues with AML workspace service deployment ([#2768](https://github.com/microsoft/AzureTRE/pull/2768)) COMPONENTS: + | name | version | | ----- | ----- | | devops | 0.4.2 | diff --git a/Makefile b/Makefile index 58719016b3..b879490f3b 100644 --- a/Makefile +++ b/Makefile @@ -218,6 +218,15 @@ bundle-install: bundle-check-params --credential-set aad_auth \ --debug +bundle: + case ${BUNDLE_TYPE} in \ + (workspace) $(MAKE) workspace_bundle BUNDLE=${BUNDLE} ;; \ + (workspace_service) $(MAKE) workspace_service_bundle BUNDLE=${BUNDLE} ;; \ + (shared_service) $(MAKE) shared_service_bundle BUNDLE=${BUNDLE} ;; \ + (user_resource) $(MAKE) user_resource_bundle WORKSPACE_SERVICE=${WORKSPACE_SERVICE} BUNDLE=${BUNDLE} ;; \ + (*) echo "Invalid BUNDLE_TYPE: ${BUNDLE_TYPE}"; exit 1 ;; \ + esac + # Validates that the parameters file is synced with the bundle. # The file is used when installing the bundle from a local machine. # We remove arm_use_msi on both sides since it shouldn't take effect locally anyway. diff --git a/airlock_processor/BlobCreatedTrigger/function.json b/airlock_processor/BlobCreatedTrigger/function.json index 5a652a8eff..c34edbeeb7 100644 --- a/airlock_processor/BlobCreatedTrigger/function.json +++ b/airlock_processor/BlobCreatedTrigger/function.json @@ -8,7 +8,9 @@ "direction": "in", "topicName": "%BLOB_CREATED_TOPIC_NAME%", "subscriptionName": "%TOPIC_SUBSCRIPTION_NAME%", - "connection": "SB_CONNECTION_STRING" + "connection": "%SERVICEBUS_CONNECTION_NAME%", + "accessRights": "listen", + "autoComplete": true }, { "type": "eventGrid", diff --git a/airlock_processor/DataDeletionTrigger/function.json b/airlock_processor/DataDeletionTrigger/function.json index 2b2bb580da..0cb7f66eab 100644 --- a/airlock_processor/DataDeletionTrigger/function.json +++ b/airlock_processor/DataDeletionTrigger/function.json @@ -7,7 +7,9 @@ "type": "serviceBusTrigger", "direction": "in", "queueName": "%AIRLOCK_DATA_DELETION_QUEUE_NAME%", - "connection": "SB_CONNECTION_STRING" + "connection": "%SERVICEBUS_CONNECTION_NAME%", + "accessRights": "listen", + "autoComplete": true } ] } diff --git a/airlock_processor/ScanResultTrigger/function.json b/airlock_processor/ScanResultTrigger/function.json index 32758cea1c..266bd059fe 100644 --- a/airlock_processor/ScanResultTrigger/function.json +++ b/airlock_processor/ScanResultTrigger/function.json @@ -7,7 +7,9 @@ "type": "serviceBusTrigger", "direction": "in", "queueName": "%AIRLOCK_SCAN_RESULT_QUEUE_NAME%", - "connection": "SB_CONNECTION_STRING" + "connection": "%SERVICEBUS_CONNECTION_NAME%", + "accessRights": "listen", + "autoComplete": true }, { "type": "eventGrid", diff --git a/airlock_processor/StatusChangedQueueTrigger/function.json b/airlock_processor/StatusChangedQueueTrigger/function.json index f686eca80a..b96de6710c 100644 --- a/airlock_processor/StatusChangedQueueTrigger/function.json +++ b/airlock_processor/StatusChangedQueueTrigger/function.json @@ -6,7 +6,9 @@ "type": "serviceBusTrigger", "direction": "in", "queueName": "%AIRLOCK_STATUS_CHANGED_QUEUE_NAME%", - "connection": "SB_CONNECTION_STRING" + "connection": "%SERVICEBUS_CONNECTION_NAME%", + "accessRights": "listen", + "autoComplete": true }, { "type": "eventGrid", diff --git a/airlock_processor/_version.py b/airlock_processor/_version.py index 8088f75131..732155f8df 100644 --- a/airlock_processor/_version.py +++ b/airlock_processor/_version.py @@ -1 +1 @@ -__version__ = "0.8.1" +__version__ = "0.8.3" diff --git a/airlock_processor/host.json b/airlock_processor/host.json index 95b6b4b7d6..f9667b1f23 100644 --- a/airlock_processor/host.json +++ b/airlock_processor/host.json @@ -8,7 +8,7 @@ } } }, - "extensionBundle": { +"extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[4.0.0, 5.0.0)" } diff --git a/resource_processor/resources/__init__.py b/airlock_processor/tests/shared_code/__init__.py similarity index 100% rename from resource_processor/resources/__init__.py rename to airlock_processor/tests/shared_code/__init__.py diff --git a/api_app/_version.py b/api_app/_version.py index 8b8252f484..76f24586d4 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.20.4" +__version__ = "0.21.1" diff --git a/api_app/api/dependencies/database.py b/api_app/api/dependencies/database.py index 7bfc89ff22..3220314e75 100644 --- a/api_app/api/dependencies/database.py +++ b/api_app/api/dependencies/database.py @@ -1,7 +1,6 @@ from azure.cosmos.aio import CosmosClient, DatabaseProxy, ContainerProxy -from azure.mgmt.cosmosdb.aio import CosmosDBManagementClient -from core.config import MANAGED_IDENTITY_CLIENT_ID, STATE_STORE_ENDPOINT, STATE_STORE_KEY, STATE_STORE_SSL_VERIFY, SUBSCRIPTION_ID, RESOURCE_MANAGER_ENDPOINT, CREDENTIAL_SCOPES, RESOURCE_GROUP_NAME, COSMOSDB_ACCOUNT_NAME, STATE_STORE_DATABASE +from core.config import STATE_STORE_ENDPOINT, STATE_STORE_KEY, STATE_STORE_SSL_VERIFY, STATE_STORE_DATABASE from core.credentials import get_credential_async from services.logging import logger @@ -27,53 +26,32 @@ def __init__(cls): async def _connect_to_db(cls) -> CosmosClient: logger.debug(f"Connecting to {STATE_STORE_ENDPOINT}") - credential = await get_credential_async() - if MANAGED_IDENTITY_CLIENT_ID: - logger.debug("Connecting with managed identity") - cosmos_client = CosmosClient( - url=STATE_STORE_ENDPOINT, - credential=credential - ) - else: + if STATE_STORE_KEY: logger.debug("Connecting with key") - primary_master_key = await cls._get_store_key(credential) - if STATE_STORE_SSL_VERIFY: logger.debug("Connecting with SSL verification") cosmos_client = CosmosClient( url=STATE_STORE_ENDPOINT, - credential=primary_master_key + credential=STATE_STORE_KEY ) else: logger.debug("Connecting without SSL verification") # ignore TLS (setup is a pain) when using local Cosmos emulator. cosmos_client = CosmosClient( url=STATE_STORE_ENDPOINT, - credential=primary_master_key, + credential=STATE_STORE_KEY, connection_verify=False ) - logger.debug("Connection established") - return cosmos_client - - @classmethod - async def _get_store_key(cls, credential) -> str: - logger.debug("Getting store key") - if STATE_STORE_KEY: - primary_master_key = STATE_STORE_KEY else: - async with CosmosDBManagementClient( - credential, - subscription_id=SUBSCRIPTION_ID, - base_url=RESOURCE_MANAGER_ENDPOINT, - credential_scopes=CREDENTIAL_SCOPES - ) as cosmosdb_mng_client: - database_keys = await cosmosdb_mng_client.database_accounts.list_keys( - resource_group_name=RESOURCE_GROUP_NAME, - account_name=COSMOSDB_ACCOUNT_NAME, - ) - primary_master_key = database_keys.primary_master_key + logger.debug("Connecting with managed identity") + credential = await get_credential_async() + cosmos_client = CosmosClient( + url=STATE_STORE_ENDPOINT, + credential=credential + ) - return primary_master_key + logger.debug("Connection established") + return cosmos_client @classmethod async def get_container_proxy(cls, container_name) -> ContainerProxy: diff --git a/api_app/api/routes/api.py b/api_app/api/routes/api.py index 0dd1a7d2c5..6dabc88275 100644 --- a/api_app/api/routes/api.py +++ b/api_app/api/routes/api.py @@ -8,7 +8,7 @@ from api.helpers import get_repository from db.repositories.workspaces import WorkspaceRepository from api.routes import health, ping, workspaces, workspace_templates, workspace_service_templates, user_resource_templates, \ - shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata + shared_services, shared_service_templates, migrations, costs, airlock, operations, metadata, requests from core import config from resources import strings @@ -49,6 +49,7 @@ core_router.include_router(migrations.migrations_core_router, tags=["migrations"]) core_router.include_router(costs.costs_core_router, tags=["costs"]) core_router.include_router(costs.costs_workspace_router, tags=["costs"]) +core_router.include_router(requests.router, tags=["requests"]) core_swagger_router = APIRouter() swagger_disabled_router = APIRouter() @@ -112,7 +113,7 @@ async def get_disabled_swagger(): def get_scope(workspace) -> str: # Cope with the fact that scope id can have api:// at the front. - return f"api://{workspace.properties['scope_id'].replace('api://','')}/user_impersonation" + return f"api://{workspace.properties['scope_id'].replace('api://', '')}/user_impersonation" @workspace_swagger_router.get("/workspaces/{workspace_id}/openapi.json", include_in_schema=False, name="openapi_definitions") diff --git a/api_app/api/routes/requests.py b/api_app/api/routes/requests.py new file mode 100644 index 0000000000..1fffa6f356 --- /dev/null +++ b/api_app/api/routes/requests.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, Depends, HTTPException, status as status_code +from typing import List, Optional + +from api.helpers import get_repository +from resources import strings +from db.repositories.airlock_requests import AirlockRequestRepository +from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType +from services.authentication import get_current_tre_user_or_tre_admin + +router = APIRouter(dependencies=[Depends(get_current_tre_user_or_tre_admin)]) + + +@router.get("/requests", response_model=List[AirlockRequest], name=strings.API_LIST_REQUESTS) +async def get_requests( + user=Depends(get_current_tre_user_or_tre_admin), + airlock_request_repo: AirlockRequestRepository = Depends(get_repository(AirlockRequestRepository)), + airlock_manager: bool = False, + creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, + order_by: Optional[str] = None, order_ascending: bool = True +) -> List[AirlockRequest]: + try: + if not airlock_manager: + requests = await airlock_request_repo.get_airlock_requests( + creator_user_id=creator_user_id or user.id, + type=type, + status=status, + order_by=order_by, + order_ascending=order_ascending, + ) + else: + requests = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user) + + return requests + + except ValueError as ve: + raise HTTPException(status_code=status_code.HTTP_400_BAD_REQUEST, detail=str(ve)) + except Exception as e: + raise HTTPException(status_code=status_code.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)) diff --git a/api_app/db/repositories/airlock_requests.py b/api_app/db/repositories/airlock_requests.py index f4a1926348..8bb8275aa6 100644 --- a/api_app/db/repositories/airlock_requests.py +++ b/api_app/db/repositories/airlock_requests.py @@ -7,6 +7,8 @@ from azure.cosmos.exceptions import CosmosResourceNotFoundError, CosmosAccessConditionFailedError from fastapi import HTTPException, status from pydantic import parse_obj_as +from db.repositories.workspaces import WorkspaceRepository +from services.authentication import get_access_service from models.domain.authentication import User from db.errors import EntityDoesNotExist from models.domain.airlock_request import AirlockFile, AirlockRequest, AirlockRequestStatus, \ @@ -107,27 +109,33 @@ def create_airlock_request_item(self, airlock_request_input: AirlockRequestInCre return airlock_request - async def get_airlock_requests(self, workspace_id: str, creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]: - query = self.airlock_requests_query() + f' WHERE c.workspaceId = "{workspace_id}"' + async def get_airlock_requests(self, workspace_id: Optional[str] = None, creator_user_id: Optional[str] = None, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]: + query = self.airlock_requests_query() # optional filters + conditions = [] + parameters = [] + if workspace_id: + conditions.append('c.workspaceId=@workspace_id') + parameters.append({"name": "@workspace_id", "value": workspace_id}) if creator_user_id: - query += ' AND c.createdBy.id=@user_id' + conditions.append('c.createdBy.id=@user_id') + parameters.append({"name": "@user_id", "value": creator_user_id}) if status: - query += ' AND c.status=@status' + conditions.append('c.status=@status') + parameters.append({"name": "@status", "value": status}) if type: - query += ' AND c.type=@type' + conditions.append('c.type=@type') + parameters.append({"name": "@type", "value": type}) + + if conditions: + query += ' WHERE ' + ' AND '.join(conditions) # optional sorting if order_by: query += ' ORDER BY c.' + order_by query += ' ASC' if order_ascending else ' DESC' - parameters = [ - {"name": "@user_id", "value": creator_user_id}, - {"name": "@status", "value": status}, - {"name": "@type", "value": type}, - ] airlock_requests = await self.query(query=query, parameters=parameters) return parse_obj_as(List[AirlockRequest], airlock_requests) @@ -138,6 +146,27 @@ async def get_airlock_request_by_id(self, airlock_request_id: UUID4) -> AirlockR raise EntityDoesNotExist return parse_obj_as(AirlockRequest, airlock_requests) + async def get_airlock_requests_for_airlock_manager(self, user: User, type: Optional[AirlockRequestType] = None, status: Optional[AirlockRequestStatus] = None, order_by: Optional[str] = None, order_ascending=True) -> List[AirlockRequest]: + workspace_repo = await WorkspaceRepository.create() + access_service = get_access_service() + + workspaces = await workspace_repo.get_active_workspaces() + user_role_assignments = access_service.get_identity_role_assignments(user.id) + + valid_roles = {ra.role_id for ra in user_role_assignments} + + workspace_ids = [ + workspace.id + for workspace in workspaces + if workspace.properties["app_role_id_workspace_airlock_manager"] in valid_roles + ] + requests = [] + + for workspace_id in workspace_ids: + requests += await self.get_airlock_requests(workspace_id=workspace_id, type=type, status=status, order_by=order_by, order_ascending=order_ascending) + + return requests + async def update_airlock_request( self, original_request: AirlockRequest, diff --git a/api_app/resources/strings.py b/api_app/resources/strings.py index a9184cf289..896b385f85 100644 --- a/api_app/resources/strings.py +++ b/api_app/resources/strings.py @@ -34,6 +34,8 @@ API_UPDATE_USER_RESOURCE = "Update an existing user resource" API_INVOKE_ACTION_ON_USER_RESOURCE = "Invoke action on a user resource" +API_LIST_REQUESTS = "Get requests" + API_CREATE_AIRLOCK_REQUEST = "Create an airlock request" API_GET_AIRLOCK_REQUEST = "Get an airlock request" API_LIST_AIRLOCK_REQUESTS = "Get all airlock requests for a workspace" diff --git a/api_app/tests_ma/conftest.py b/api_app/tests_ma/conftest.py index 0bd06e076d..6245ec23ec 100644 --- a/api_app/tests_ma/conftest.py +++ b/api_app/tests_ma/conftest.py @@ -578,7 +578,6 @@ def simple_pipeline_step() -> PipelineStep: @pytest_asyncio.fixture(autouse=True) async def no_database(): with patch('api.dependencies.database.get_credential_async', return_value=AsyncMock()), \ - patch('api.dependencies.database.CosmosDBManagementClient', return_value=AsyncMock()), \ - patch('api.dependencies.database.CosmosClient', return_value=AsyncMock(spec=CosmosClient)) as cosmos_client_mock: + patch('api.dependencies.database.CosmosClient', return_value=AsyncMock(spec=CosmosClient)) as cosmos_client_mock: cosmos_client_mock.return_value.get_database_client.return_value = AsyncMock(spec=DatabaseProxy) yield Database() diff --git a/api_app/tests_ma/test_api/test_routes/test_requests.py b/api_app/tests_ma/test_api/test_routes/test_requests.py new file mode 100644 index 0000000000..4a10544ca1 --- /dev/null +++ b/api_app/tests_ma/test_api/test_routes/test_requests.py @@ -0,0 +1,42 @@ +import pytest +from fastapi import status +from mock import patch + +from resources import strings +from services.authentication import get_current_tre_user_or_tre_admin + + +pytestmark = pytest.mark.asyncio + + +class TestRequestsThatDontRequireAdminRigths: + @pytest.fixture(autouse=True, scope='class') + def log_in_with_non_admin_user(self, app, non_admin_user): + with patch('services.aad_authentication.AzureADAuthorization._get_user_from_token', return_value=non_admin_user()): + app.dependency_overrides[get_current_tre_user_or_tre_admin] = non_admin_user + yield + app.dependency_overrides = {} + + # [GET] /requests/ - get_requests + @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests", return_value=[]) + async def test_get_all_requests_returns_200(self, _, app, client): + response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS)) + assert response.status_code == status.HTTP_200_OK + + @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests_for_airlock_manager") + async def test_get_airlock_manager_requests_returns_200(self, mock_get_airlock_requests_for_airlock_manager, app, client): + mock_get_airlock_requests_for_airlock_manager.return_value = [] + response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True}) + + assert response.status_code == status.HTTP_200_OK + mock_get_airlock_requests_for_airlock_manager.assert_called_once() + + @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests", side_effect=Exception("Internal Server Error")) + async def test_get_all_requests_returns_500(self, _, app, client): + response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS)) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR + + @patch("api.routes.airlock.AirlockRequestRepository.get_airlock_requests_for_airlock_manager", side_effect=Exception("Internal Server Error")) + async def test_get_airlock_manager_requests_returns_500(self, _, app, client): + response = await client.get(app.url_path_for(strings.API_LIST_REQUESTS), params={"airlock_manager": True}) + assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR diff --git a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py index 4c773db327..92ae8b7ae7 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_airlock_request_repository.py @@ -3,6 +3,8 @@ from mock import patch import pytest import pytest_asyncio +from models.domain.authentication import RoleAssignment, User +from models.domain.workspace import Workspace from tests_ma.test_api.conftest import create_test_user from models.schemas.airlock_request import AirlockRequestInCreate from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType @@ -63,6 +65,18 @@ def verify_dictionary_contains_all_enum_values(): raise Exception(f"Status '{status}' was not added to the ALLOWED_STATUS_CHANGES dictionary") +def sample_workspace(workspace_id=WORKSPACE_ID, workspace_properties: dict = {}) -> Workspace: + workspace = Workspace( + id=workspace_id, + templateName="tre-workspace-base", + templateVersion="0.1.0", + etag="", + properties=workspace_properties, + resourcePath=f'/workspaces/{workspace_id}' + ) + return workspace + + def airlock_request_mock(status=AirlockRequestStatus.Draft): airlock_request = AirlockRequest( id=AIRLOCK_REQUEST_ID, @@ -143,12 +157,186 @@ async def test_update_airlock_request_should_retry_update_when_etag_is_not_up_to async def test_get_airlock_requests_queries_db(airlock_request_repo): airlock_request_repo.container.query_items = MagicMock() - expected_query = airlock_request_repo.airlock_requests_query() + f' WHERE c.workspaceId = "{WORKSPACE_ID}"' + expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.workspaceId=@workspace_id' expected_parameters = [ - {"name": "@user_id", "value": None}, - {"name": "@status", "value": None}, - {"name": "@type", "value": None}, + {"name": "@workspace_id", "value": WORKSPACE_ID}, ] await airlock_request_repo.get_airlock_requests(WORKSPACE_ID) airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters) + + +async def test_get_airlock_requests_with_user_id(airlock_request_repo): + airlock_request_repo.container.query_items = MagicMock() + user_id = "test_user_id" + expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.createdBy.id=@user_id' + expected_parameters = [ + {"name": "@user_id", "value": user_id}, + ] + + await airlock_request_repo.get_airlock_requests(creator_user_id=user_id) + airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters) + + +async def test_get_airlock_requests_with_status(airlock_request_repo): + airlock_request_repo.container.query_items = MagicMock() + status = AirlockRequestStatus.Submitted + expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.status=@status' + expected_parameters = [ + {"name": "@status", "value": status} + ] + + await airlock_request_repo.get_airlock_requests(status=status) + airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters) + + +async def test_get_airlock_requests_with_type(airlock_request_repo): + airlock_request_repo.container.query_items = MagicMock() + request_type = AirlockRequestType.Import + expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.type=@type' + expected_parameters = [ + {"name": "@type", "value": request_type}, + ] + + await airlock_request_repo.get_airlock_requests(type=request_type) + airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters) + + +async def test_get_airlock_requests_with_multiple_filters(airlock_request_repo): + airlock_request_repo.container.query_items = MagicMock() + user_id = "test_user_id" + status = AirlockRequestStatus.Submitted + request_type = AirlockRequestType.Import + expected_query = airlock_request_repo.airlock_requests_query() + ' WHERE c.createdBy.id=@user_id AND c.status=@status AND c.type=@type' + expected_parameters = [ + {"name": "@user_id", "value": user_id}, + {"name": "@status", "value": status}, + {"name": "@type", "value": request_type}, + ] + + await airlock_request_repo.get_airlock_requests(creator_user_id=user_id, status=status, type=request_type) + airlock_request_repo.container.query_items.assert_called_once_with(query=expected_query, parameters=expected_parameters) + + +@pytest.mark.asyncio +@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock) +@patch('db.repositories.airlock_requests.get_access_service', autospec=True) +@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True) +async def test_get_airlock_requests_for_airlock_manager_no_roles( + mock_workspace_repo, + mock_access_service, + mock_get_requests, + airlock_request_repo +): + # Mock no user roles + mock_access_service.return_value.get_identity_role_assignments.return_value = [] + + # Mock active workspaces + mock_workspace_instance = MagicMock() + mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[]) + mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance) + + # Call function + user = User(id="user1", name="TestUser") + result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user) + + # validate + assert result == [] + mock_get_requests.assert_not_called() + + +@pytest.mark.asyncio +@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock) +@patch('db.repositories.airlock_requests.get_access_service', autospec=True) +@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True) +async def test_get_airlock_requests_for_airlock_manager_single_workspace( + mock_workspace_repo, + mock_access_service, + mock_get_requests, + airlock_request_repo +): + # Setup workspace and manager role + workspace = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"}) + mock_workspace_instance = MagicMock() + mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace]) + mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance) + + # Setup user roles + role_assignment = RoleAssignment(resource_id="resource_id", role_id="manager-role-1") + mock_access_service.return_value.get_identity_role_assignments.return_value = [role_assignment] + + # Setup corresponding requests from that workspace + request_mock = AirlockRequest(id="request-1", workspaceId=WORKSPACE_ID, type=AirlockRequestType.Import, reviews=[]) + mock_get_requests.return_value = [request_mock] + + user = User(id="user1", name="TestUser") + result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user) + + assert len(result) == 1 + assert result[0].id == "request-1" + mock_get_requests.assert_called_once_with(workspace_id=WORKSPACE_ID, type=None, status=None, order_by=None, order_ascending=True) + + +@pytest.mark.asyncio +@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock) +@patch('db.repositories.airlock_requests.get_access_service', autospec=True) +@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True) +async def test_get_airlock_requests_for_airlock_manager_multiple_workspaces( + mock_workspace_repo, + mock_access_service, + mock_get_requests, + airlock_request_repo +): + # Setup multiple workspaces + workspace1 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"}) + workspace2 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-2"}) + mock_workspace_instance = MagicMock() + mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace1, workspace2]) + mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance) + + # Setup user roles + role_assignment_1 = RoleAssignment(resource_id="resource_id", role_id="manager-role-1") + role_assignment_2 = RoleAssignment(resource_id="resource_id", role_id="manager-role-2") + mock_access_service.return_value.get_identity_role_assignments.return_value = [role_assignment_1, role_assignment_2] + + # Setup requests for each workspace + first_ws_requests = [AirlockRequest(id="request-1", workspaceId="workspace-1", type=AirlockRequestType.Import, reviews=[])] + second_ws_requests = [AirlockRequest(id="request-2", workspaceId="workspace-2", type=AirlockRequestType.Import, reviews=[])] + mock_get_requests.side_effect = [first_ws_requests, second_ws_requests] + + user = User(id="user1", name="TestUser") + result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user) + + # combined requests from both + assert len(result) == 2 + assert result[0].id == "request-1" + assert result[1].id == "request-2" + assert mock_get_requests.call_count == 2 + + +@pytest.mark.asyncio +@patch.object(AirlockRequestRepository, 'get_airlock_requests', new_callable=AsyncMock) +@patch('db.repositories.airlock_requests.get_access_service', autospec=True) +@patch('db.repositories.airlock_requests.WorkspaceRepository', autospec=True) +async def test_get_airlock_requests_for_airlock_manager_active_workspaces_but_no_manager_role( + mock_workspace_repo, + mock_access_service, + mock_get_requests, + airlock_request_repo +): + # Setup multiple workspaces, but user doesn't have manager roles + workspace1 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-1"}) + workspace2 = sample_workspace(workspace_properties={"app_role_id_workspace_airlock_manager": "manager-role-2"}) + mock_workspace_instance = MagicMock() + mock_workspace_instance.get_active_workspaces = AsyncMock(return_value=[workspace1, workspace2]) + mock_workspace_repo.create = AsyncMock(return_value=mock_workspace_instance) + + # No matching roles for these workspaces + mock_access_service.return_value.get_identity_role_assignments.return_value = [ + RoleAssignment(resource_id="resource_id", role_id="some-other-role") + ] + + user = User(id="user1", name="TestUser") + result = await airlock_request_repo.get_airlock_requests_for_airlock_manager(user) + assert result == [] + mock_get_requests.assert_not_called() diff --git a/core/terraform/.terraform.lock.hcl b/core/terraform/.terraform.lock.hcl index 1c20359910..41d8da1a19 100644 --- a/core/terraform/.terraform.lock.hcl +++ b/core/terraform/.terraform.lock.hcl @@ -6,7 +6,6 @@ provider "registry.terraform.io/azure/azapi" { constraints = ">= 1.15.0, ~> 1.15.0" hashes = [ "h1:Y7ruMuPh8UJRTRl4rm+cdpGtmURx2taqiuqfYaH3o48=", - "h1:gIOgxVmFSxHrR+XOzgUEA+ybOmp8kxZlZH3eYeB/eFI=", "zh:0627a8bc77254debc25dc0c7b62e055138217c97b03221e593c3c56dc7550671", "zh:2fe045f07070ef75d0bec4b0595a74c14394daa838ddb964e2fd23cc98c40c34", "zh:343009f39c957883b2c06145a5954e524c70f93585f943f1ea3d28ef6995d0d0", @@ -23,22 +22,22 @@ provider "registry.terraform.io/azure/azapi" { } provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.0" - constraints = ">= 3.117.0, 3.117.0" + version = "4.14.0" + constraints = ">= 3.117.0, 4.14.0" hashes = [ - "h1:Ynfg+Iy7x6K8M6W1AhqXCe3wkoiqIQhROlca7C3KC3w=", - "zh:2e25f47492366821a786762369f0e0921cc9452d64bfd5075f6fdfcf1a9c6d70", - "zh:41eb34f2f7469bf3eb1019dfb0e7fc28256f809824016f4f8b9d691bf473b2ac", - "zh:48bb9c87b3d928da1abc1d3db75453c9725de4674c612daf3800160cc7145d30", - "zh:5d6b0de0bbd78943fcc65c53944ef4496329e247f434c6eab86ed051c5cea67b", - "zh:78c9f6fdb1206a89cf0e6706b4f46178169a93b6c964a4cad8a321058ccbd9b4", - "zh:793b702c352589d4360b580d4a1cf654a7439d2ad6bdb7bfea91de07bc4b0fac", - "zh:7ed687ff0a5509463a592f97431863574fe5cc80a34e395be06766215b8c6285", - "zh:955ba18789bd15592824eb426a8d0f38595bd09fffc6939c1c58933489c1a71e", - "zh:bf5949a55be0714cd9c8815d472eae4baa48ba06d0f6bf2b96775869acda8a54", - "zh:da5d31f635abd2c645ffc76d6176d73f646128e73720cc368247cc424975c127", - "zh:eed5a66d59883c9c56729b0a964a2b60d758ea7489ef3e920a6fbd48518ce5f5", + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", ] } diff --git a/core/terraform/airlock/airlock_processor.tf b/core/terraform/airlock/airlock_processor.tf index ccb36b81bb..d293927ea0 100644 --- a/core/terraform/airlock/airlock_processor.tf +++ b/core/terraform/airlock/airlock_processor.tf @@ -66,21 +66,32 @@ resource "azurerm_linux_function_app" "airlock_function_app" { } app_settings = { - "SB_CONNECTION_STRING" = var.airlock_servicebus.default_primary_connection_string - "BLOB_CREATED_TOPIC_NAME" = azurerm_servicebus_topic.blob_created.name - "TOPIC_SUBSCRIPTION_NAME" = azurerm_servicebus_subscription.airlock_processor.name - "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = false - "AIRLOCK_STATUS_CHANGED_QUEUE_NAME" = local.status_changed_queue_name - "AIRLOCK_SCAN_RESULT_QUEUE_NAME" = local.scan_result_queue_name - "AIRLOCK_DATA_DELETION_QUEUE_NAME" = local.data_deletion_queue_name - "ENABLE_MALWARE_SCANNING" = var.enable_malware_scanning - "ARM_ENVIRONMENT" = var.arm_environment - "MANAGED_IDENTITY_CLIENT_ID" = azurerm_user_assigned_identity.airlock_id.client_id - "TRE_ID" = var.tre_id - "WEBSITE_CONTENTOVERVNET" = 1 - "STORAGE_ENDPOINT_SUFFIX" = module.terraform_azurerm_environment_configuration.storage_suffix - "AzureWebJobsStorage__clientId" = azurerm_user_assigned_identity.airlock_id.client_id - "AzureWebJobsStorage__credential" = "managedidentity" + "SERVICEBUS_CONNECTION_NAME" = local.servicebus_connection + "${local.servicebus_connection}__tenantId" = azurerm_user_assigned_identity.airlock_id.tenant_id + "${local.servicebus_connection}__clientId" = azurerm_user_assigned_identity.airlock_id.client_id + "${local.servicebus_connection}__credential" = "managedidentity" + "${local.servicebus_connection}__fullyQualifiedNamespace" = var.airlock_servicebus_fqdn + + "BLOB_CREATED_TOPIC_NAME" = azurerm_servicebus_topic.blob_created.name + "TOPIC_SUBSCRIPTION_NAME" = azurerm_servicebus_subscription.airlock_processor.name + "EVENT_GRID_STEP_RESULT_TOPIC_URI_SETTING" = azurerm_eventgrid_topic.step_result.endpoint + "EVENT_GRID_STEP_RESULT_TOPIC_KEY_SETTING" = azurerm_eventgrid_topic.step_result.primary_access_key + "EVENT_GRID_DATA_DELETION_TOPIC_URI_SETTING" = azurerm_eventgrid_topic.data_deletion.endpoint + "EVENT_GRID_DATA_DELETION_TOPIC_KEY_SETTING" = azurerm_eventgrid_topic.data_deletion.primary_access_key + "WEBSITES_ENABLE_APP_SERVICE_STORAGE" = false + "AIRLOCK_STATUS_CHANGED_QUEUE_NAME" = local.status_changed_queue_name + "AIRLOCK_SCAN_RESULT_QUEUE_NAME" = local.scan_result_queue_name + "AIRLOCK_DATA_DELETION_QUEUE_NAME" = local.data_deletion_queue_name + "ENABLE_MALWARE_SCANNING" = var.enable_malware_scanning + "ARM_ENVIRONMENT" = var.arm_environment + "MANAGED_IDENTITY_CLIENT_ID" = azurerm_user_assigned_identity.airlock_id.client_id + "TRE_ID" = var.tre_id + "WEBSITE_CONTENTOVERVNET" = 1 + "STORAGE_ENDPOINT_SUFFIX" = module.terraform_azurerm_environment_configuration.storage_suffix + + "TOPIC_SUBSCRIPTION_NAME" = azurerm_servicebus_subscription.airlock_processor.name + "AzureWebJobsStorage__clientId" = azurerm_user_assigned_identity.airlock_id.client_id + "AzureWebJobsStorage__credential" = "managedidentity" "EVENT_GRID_STEP_RESULT_CONNECTION" = local.step_result_eventgrid_connection "${local.step_result_eventgrid_connection}__topicEndpointUri" = azurerm_eventgrid_topic.step_result.endpoint @@ -100,6 +111,7 @@ resource "azurerm_linux_function_app" "airlock_function_app" { container_registry_use_managed_identity = true vnet_route_all_enabled = true ftps_state = "Disabled" + minimum_tls_version = "1.3" application_stack { docker { diff --git a/core/terraform/airlock/locals.tf b/core/terraform/airlock/locals.tf index 8ed6805e0e..838ddf091a 100644 --- a/core/terraform/airlock/locals.tf +++ b/core/terraform/airlock/locals.tf @@ -61,6 +61,7 @@ locals { azurerm_storage_account.sa_export_approved.id ] + servicebus_connection = "SERVICEBUS_CONNECTION" step_result_eventgrid_connection = "EVENT_GRID_STEP_RESULT_CONNECTION" data_deletion_eventgrid_connection = "EVENT_GRID_DATA_DELETION_CONNECTION" } diff --git a/core/terraform/airlock/main.tf b/core/terraform/airlock/main.tf index cc76da3210..ee659770f9 100644 --- a/core/terraform/airlock/main.tf +++ b/core/terraform/airlock/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = ">= 3.117" + version = ">= 4.14.0" } azapi = { source = "Azure/azapi" diff --git a/core/terraform/airlock/variables.tf b/core/terraform/airlock/variables.tf index 95e03b4ba4..bb0fad04df 100644 --- a/core/terraform/airlock/variables.tf +++ b/core/terraform/airlock/variables.tf @@ -62,6 +62,9 @@ variable "airlock_servicebus" { default_primary_connection_string = string }) } +variable "airlock_servicebus_fqdn" { + type = string +} variable "tre_core_tags" { type = map(string) } diff --git a/core/terraform/api-webapp.tf b/core/terraform/api-webapp.tf index 9d9fb65dd1..b35cc0ba7c 100644 --- a/core/terraform/api-webapp.tf +++ b/core/terraform/api-webapp.tf @@ -83,7 +83,7 @@ resource "azurerm_linux_web_app" "api" { vnet_route_all_enabled = true container_registry_use_managed_identity = true container_registry_managed_identity_client_id = azurerm_user_assigned_identity.id.client_id - minimum_tls_version = "1.2" + minimum_tls_version = "1.3" ftps_state = "Disabled" application_stack { diff --git a/core/terraform/cosmos_mongo.tf b/core/terraform/cosmos_mongo.tf index 65812cc8f1..b43c060ac7 100644 --- a/core/terraform/cosmos_mongo.tf +++ b/core/terraform/cosmos_mongo.tf @@ -1,12 +1,13 @@ resource "azurerm_cosmosdb_account" "mongo" { - name = "cosmos-mongo-${var.tre_id}" - location = azurerm_resource_group.core.location - resource_group_name = azurerm_resource_group.core.name - offer_type = "Standard" - kind = "MongoDB" - automatic_failover_enabled = false - mongo_server_version = 4.2 - ip_range_filter = "${local.azure_portal_cosmos_ips}${var.enable_local_debugging ? ",${local.myip}" : ""}" + name = "cosmos-mongo-${var.tre_id}" + location = azurerm_resource_group.core.location + resource_group_name = azurerm_resource_group.core.name + offer_type = "Standard" + kind = "MongoDB" + automatic_failover_enabled = false + mongo_server_version = 4.2 + ip_range_filter = local.cosmos_ip_filter_set + public_network_access_enabled = var.enable_local_debugging capabilities { name = "EnableServerless" diff --git a/core/terraform/deploy.sh b/core/terraform/deploy.sh index 148cf1aca4..ef547a8aa7 100755 --- a/core/terraform/deploy.sh +++ b/core/terraform/deploy.sh @@ -5,6 +5,9 @@ set -o pipefail set -o nounset # set -o xtrace +# shellcheck disable=SC1091 +source "../../devops/scripts/kv_add_network_exception.sh" + # This is where we can migrate any Terraform before we plan and apply # For instance deprecated Terraform resources # shellcheck disable=SC1091 diff --git a/core/terraform/destroy.sh b/core/terraform/destroy.sh index 92b6b75c4c..7c8506beef 100755 --- a/core/terraform/destroy.sh +++ b/core/terraform/destroy.sh @@ -5,6 +5,9 @@ set -o pipefail set -o nounset # set -o xtrace +# shellcheck disable=SC1091 +source "../../devops/scripts/kv_add_network_exception.sh" + # These variables are loaded in for us # shellcheck disable=SC2154 ../../devops/scripts/terraform_wrapper.sh -g "${TF_VAR_mgmt_resource_group_name}" \ diff --git a/core/terraform/keyvault.tf b/core/terraform/keyvault.tf index 5d75ae9176..c491a09517 100644 --- a/core/terraform/keyvault.tf +++ b/core/terraform/keyvault.tf @@ -1,5 +1,5 @@ resource "azurerm_key_vault" "kv" { - name = "kv-${var.tre_id}" + name = local.kv_name tenant_id = data.azurerm_client_config.current.tenant_id location = azurerm_resource_group.core.location resource_group_name = azurerm_resource_group.core.name @@ -8,7 +8,27 @@ resource "azurerm_key_vault" "kv" { purge_protection_enabled = var.kv_purge_protection_enabled tags = local.tre_core_tags - lifecycle { ignore_changes = [access_policy, tags] } + public_network_access_enabled = local.kv_public_network_access_enabled + + network_acls { + default_action = local.kv_network_default_action + bypass = local.kv_network_bypass + ip_rules = [local.myip] # exception for deployment IP, this is removed in kv_remove_network_exception.sh + } + + lifecycle { + ignore_changes = [access_policy, tags] + } + + # create provisioner required due to https://github.com/hashicorp/terraform-provider-azurerm/issues/18970 + # + provisioner "local-exec" { + when = create + command = < azurerm_service_plan -# core_app_service_plan_id=$(echo "${terraform_show_json}" \ -# | jq -r 'select(.values.root_module.resources != null) | .values.root_module.resources[] | select(.address=="azurerm_app_service_plan.core") | .values.id') -# if [ -n "${core_app_service_plan_id}" ]; then -# echo "Migrating ${core_app_service_plan_id}" -# terraform state rm azurerm_app_service_plan.core -# if [[ $(az resource list --query "[?id=='${core_app_service_plan_id}'] | length(@)") == 0 ]]; -# then -# echo "The resource doesn't exist on Azure. Skipping importing it back to state." -# else -# terraform import azurerm_service_plan.core "${core_app_service_plan_id}" -# fi -# fi - -echo "*** Migration is done. ***" + # Remove and re-import the VNet + vnet_address="module.network.azurerm_virtual_network.core" + vnet_id=$(get_resource_id "${terraform_show_json}" "$vnet_address" "vnet") + if [ -n "${vnet_id}" ] && [ "${vnet_id}" != "null" ]; then + terraform state rm "${vnet_address}" + terraform import "${vnet_address}" "${vnet_id}" + else + echo "VNet resource not found in state: ${vnet_address}" + fi + echo "*** Migration Done ***" +else + echo "No old resources found in the state, skipping migration." + echo "*** Migration Skipped ***" +fi diff --git a/core/terraform/network/.terraform.lock.hcl b/core/terraform/network/.terraform.lock.hcl new file mode 100644 index 0000000000..ec690305f6 --- /dev/null +++ b/core/terraform/network/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.14.0" + constraints = "4.14.0" + hashes = [ + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", + ] +} diff --git a/core/terraform/network/locals.tf b/core/terraform/network/locals.tf index aaa2aea7d1..82ae26fb2d 100644 --- a/core/terraform/network/locals.tf +++ b/core/terraform/network/locals.tf @@ -32,4 +32,6 @@ locals { "privatelink.queue.core.windows.net", "privatelink.table.core.windows.net" ]) + + subnet_ids_map = { for subnet in azurerm_virtual_network.core.subnet : subnet.name => subnet.id } } diff --git a/core/terraform/network/main.tf b/core/terraform/network/main.tf index a4eb095f9c..5cced47bb0 100644 --- a/core/terraform/network/main.tf +++ b/core/terraform/network/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = ">= 3.117" + version = ">= 4.14.0" } } } diff --git a/core/terraform/network/network.tf b/core/terraform/network/network.tf index db71fe554f..a511365326 100644 --- a/core/terraform/network/network.tf +++ b/core/terraform/network/network.tf @@ -5,146 +5,112 @@ resource "azurerm_virtual_network" "core" { address_space = [var.core_address_space] tags = local.tre_core_tags lifecycle { ignore_changes = [tags] } -} -resource "azurerm_subnet" "bastion" { - name = "AzureBastionSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.bastion_subnet_address_prefix] -} + subnet { + name = "AzureBastionSubnet" + address_prefixes = [local.bastion_subnet_address_prefix] + security_group = azurerm_network_security_group.bastion.id + } -resource "azurerm_subnet" "azure_firewall" { - name = "AzureFirewallSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.firewall_subnet_address_space] - depends_on = [azurerm_subnet.bastion] -} + subnet { + name = "AzureFirewallSubnet" + address_prefixes = [local.firewall_subnet_address_space] + } -resource "azurerm_subnet" "app_gw" { - name = "AppGwSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.app_gw_subnet_address_prefix] - private_endpoint_network_policies = "Disabled" - private_link_service_network_policies_enabled = true - depends_on = [azurerm_subnet.azure_firewall] -} + subnet { + name = "AppGwSubnet" + address_prefixes = [local.app_gw_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + private_link_service_network_policies_enabled = true + security_group = azurerm_network_security_group.app_gw.id + } -resource "azurerm_subnet" "web_app" { - name = "WebAppSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.web_app_subnet_address_prefix] - private_endpoint_network_policies = "Disabled" - private_link_service_network_policies_enabled = true - depends_on = [azurerm_subnet.app_gw] - - delegation { - name = "delegation" - - service_delegation { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + subnet { + name = "WebAppSubnet" + address_prefixes = [local.web_app_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + private_link_service_network_policies_enabled = true + security_group = azurerm_network_security_group.default_rules.id + + delegation { + name = "delegation" + + service_delegation { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } } } -} -resource "azurerm_subnet" "shared" { - name = "SharedSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.shared_services_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.web_app] -} + subnet { + name = "SharedSubnet" + address_prefixes = [local.shared_services_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id + } -resource "azurerm_subnet" "resource_processor" { - name = "ResourceProcessorSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.resource_processor_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.shared] -} + subnet { + name = "ResourceProcessorSubnet" + address_prefixes = [local.resource_processor_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id + } + + subnet { + name = "AirlockProcessorSubnet" + address_prefixes = [local.airlock_processor_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id -resource "azurerm_subnet" "airlock_processor" { - name = "AirlockProcessorSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.airlock_processor_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.resource_processor] - - delegation { - name = "delegation" - - service_delegation { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + delegation { + name = "delegation" + + service_delegation { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } } + + service_endpoints = ["Microsoft.Storage"] } - # Todo: needed as we want to open the fw for this subnet in some of the airlock storages (export inprogress) - # https://github.com/microsoft/AzureTRE/issues/2098 - service_endpoints = ["Microsoft.Storage"] -} + subnet { + name = "AirlockNotifiactionSubnet" + address_prefixes = [local.airlock_notifications_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id + + delegation { + name = "delegation" -resource "azurerm_subnet" "airlock_notification" { - name = "AirlockNotifiactionSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.airlock_notifications_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.airlock_processor] - - delegation { - name = "delegation" - - service_delegation { - name = "Microsoft.Web/serverFarms" - actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + service_delegation { + name = "Microsoft.Web/serverFarms" + actions = ["Microsoft.Network/virtualNetworks/subnets/action"] + } } + service_endpoints = ["Microsoft.ServiceBus"] } - service_endpoints = ["Microsoft.ServiceBus"] -} -resource "azurerm_subnet" "airlock_storage" { - name = "AirlockStorageSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.airlock_storage_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.airlock_notification] -} + subnet { + name = "AirlockStorageSubnet" + address_prefixes = [local.airlock_storage_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id + } -resource "azurerm_subnet" "airlock_events" { - name = "AirlockEventsSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.airlock_events_subnet_address_prefix] - # notice that private endpoints do not adhere to NSG rules - private_endpoint_network_policies = "Disabled" - depends_on = [azurerm_subnet.airlock_storage] - - # Eventgrid CAN'T send messages over private endpoints, hence we need to allow service endpoints to the service bus - # We are using service endpoints + managed identity to send these messaages - # https://docs.microsoft.com/en-us/azure/event-grid/consume-private-endpoints - service_endpoints = ["Microsoft.ServiceBus"] -} + subnet { + name = "AirlockEventsSubnet" + address_prefixes = [local.airlock_events_subnet_address_prefix] + private_endpoint_network_policies = "Disabled" + security_group = azurerm_network_security_group.default_rules.id -resource "azurerm_subnet" "firewall_management" { - name = "AzureFirewallManagementSubnet" - virtual_network_name = azurerm_virtual_network.core.name - resource_group_name = var.resource_group_name - address_prefixes = [local.firewall_management_subnet_address_prefix] - depends_on = [azurerm_subnet.airlock_events] + service_endpoints = ["Microsoft.ServiceBus"] + } + + subnet { + name = "AzureFirewallManagementSubnet" + address_prefixes = [local.firewall_management_subnet_address_prefix] + } } resource "azurerm_ip_group" "resource_processor" { diff --git a/core/terraform/network/network_security_groups.tf b/core/terraform/network/network_security_groups.tf index 50accf846b..34371dc145 100644 --- a/core/terraform/network/network_security_groups.tf +++ b/core/terraform/network/network_security_groups.tf @@ -105,13 +105,6 @@ resource "azurerm_network_security_group" "bastion" { lifecycle { ignore_changes = [tags] } } -resource "azurerm_subnet_network_security_group_association" "bastion" { - subnet_id = azurerm_subnet.bastion.id - network_security_group_id = azurerm_network_security_group.bastion.id - # depend on the last subnet we created in the vnet - depends_on = [azurerm_subnet.firewall_management] -} - # Network security group for Application Gateway # See https://docs.microsoft.com/azure/application-gateway/configuration-infrastructure#network-security-groups resource "azurerm_network_security_group" "app_gw" { @@ -147,12 +140,6 @@ resource "azurerm_network_security_group" "app_gw" { lifecycle { ignore_changes = [tags] } } -resource "azurerm_subnet_network_security_group_association" "app_gw" { - subnet_id = azurerm_subnet.app_gw.id - network_security_group_id = azurerm_network_security_group.app_gw.id - depends_on = [azurerm_subnet_network_security_group_association.bastion] -} - # Network security group with only default security rules # See https://docs.microsoft.com/azure/virtual-network/network-security-groups-overview#default-security-rules resource "azurerm_network_security_group" "default_rules" { @@ -163,45 +150,3 @@ resource "azurerm_network_security_group" "default_rules" { lifecycle { ignore_changes = [tags] } } - -resource "azurerm_subnet_network_security_group_association" "shared" { - subnet_id = azurerm_subnet.shared.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.app_gw] -} - -resource "azurerm_subnet_network_security_group_association" "web_app" { - subnet_id = azurerm_subnet.web_app.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.shared] -} - -resource "azurerm_subnet_network_security_group_association" "resource_processor" { - subnet_id = azurerm_subnet.resource_processor.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.web_app] -} - -resource "azurerm_subnet_network_security_group_association" "airlock_processor" { - subnet_id = azurerm_subnet.airlock_processor.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.resource_processor] -} - -resource "azurerm_subnet_network_security_group_association" "airlock_storage" { - subnet_id = azurerm_subnet.airlock_storage.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.airlock_processor] -} - -resource "azurerm_subnet_network_security_group_association" "airlock_events" { - subnet_id = azurerm_subnet.airlock_events.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.airlock_storage] -} - -resource "azurerm_subnet_network_security_group_association" "airlock_notification" { - subnet_id = azurerm_subnet.airlock_notification.id - network_security_group_id = azurerm_network_security_group.default_rules.id - depends_on = [azurerm_subnet_network_security_group_association.airlock_events] -} diff --git a/core/terraform/network/outputs.tf b/core/terraform/network/outputs.tf index 3e0aab407d..e2a7fba134 100644 --- a/core/terraform/network/outputs.tf +++ b/core/terraform/network/outputs.tf @@ -3,43 +3,43 @@ output "core_vnet_id" { } output "bastion_subnet_id" { - value = azurerm_subnet.bastion.id + value = local.subnet_ids_map["AzureBastionSubnet"] } output "azure_firewall_subnet_id" { - value = azurerm_subnet.azure_firewall.id + value = local.subnet_ids_map["AzureFirewallSubnet"] } output "app_gw_subnet_id" { - value = azurerm_subnet.app_gw.id + value = local.subnet_ids_map["AppGwSubnet"] } output "web_app_subnet_id" { - value = azurerm_subnet.web_app.id + value = local.subnet_ids_map["WebAppSubnet"] } output "shared_subnet_id" { - value = azurerm_subnet.shared.id + value = local.subnet_ids_map["SharedSubnet"] } output "airlock_processor_subnet_id" { - value = azurerm_subnet.airlock_processor.id + value = local.subnet_ids_map["AirlockProcessorSubnet"] } output "airlock_storage_subnet_id" { - value = azurerm_subnet.airlock_storage.id + value = local.subnet_ids_map["AirlockStorageSubnet"] } output "airlock_events_subnet_id" { - value = azurerm_subnet.airlock_events.id + value = local.subnet_ids_map["AirlockEventsSubnet"] } output "resource_processor_subnet_id" { - value = azurerm_subnet.resource_processor.id + value = local.subnet_ids_map["ResourceProcessorSubnet"] } output "airlock_notification_subnet_id" { - value = azurerm_subnet.airlock_notification.id + value = local.subnet_ids_map["AirlockNotifiactionSubnet"] } # DNS Zones diff --git a/core/terraform/scripts/letsencrypt.sh b/core/terraform/scripts/letsencrypt.sh index cb1e68f4a1..c2b6d0c5d0 100755 --- a/core/terraform/scripts/letsencrypt.sh +++ b/core/terraform/scripts/letsencrypt.sh @@ -8,6 +8,11 @@ if [[ -z ${STORAGE_ACCOUNT} ]]; then exit 1 fi +if [[ -n ${KEYVAULT} ]]; then + # shellcheck disable=SC1091 + source "$script_dir/../../../devops/scripts/kv_add_network_exception.sh" +fi + # The storage account is protected by network rules # # The rules need to be temporarily lifted so that the script can determine if the index.html file diff --git a/core/terraform/servicebus.tf b/core/terraform/servicebus.tf index f686a8e08e..7c03d661c0 100644 --- a/core/terraform/servicebus.tf +++ b/core/terraform/servicebus.tf @@ -5,6 +5,7 @@ resource "azurerm_servicebus_namespace" "sb" { sku = "Premium" premium_messaging_partitions = "1" capacity = "1" + local_auth_enabled = false tags = local.tre_core_tags # Block public access diff --git a/core/terraform/statestore.tf b/core/terraform/statestore.tf index 66748fda58..69b1ba74c3 100644 --- a/core/terraform/statestore.tf +++ b/core/terraform/statestore.tf @@ -5,7 +5,8 @@ resource "azurerm_cosmosdb_account" "tre_db_account" { offer_type = "Standard" kind = "GlobalDocumentDB" automatic_failover_enabled = false - ip_range_filter = "${local.azure_portal_cosmos_ips}${var.enable_local_debugging ? ",${local.myip}" : ""}" + public_network_access_enabled = var.enable_local_debugging + ip_range_filter = local.cosmos_ip_filter_set local_authentication_disabled = true tags = local.tre_core_tags diff --git a/core/version.txt b/core/version.txt index 663d6b3572..8e1395bd35 100644 --- a/core/version.txt +++ b/core/version.txt @@ -1 +1 @@ -__version__ = "0.11.22" +__version__ = "0.12.3" diff --git a/devops/scripts/destroy_env_no_terraform.sh b/devops/scripts/destroy_env_no_terraform.sh index e327f313c6..628d8a5d65 100755 --- a/devops/scripts/destroy_env_no_terraform.sh +++ b/devops/scripts/destroy_env_no_terraform.sh @@ -66,6 +66,11 @@ then no_wait_option="--no-wait" fi +script_dir=$(realpath "$(dirname "${BASH_SOURCE[0]}")") + +# shellcheck disable=SC1091 +source "$script_dir/kv_add_network_exception.sh" + group_show_result=$(az group show --name "${core_tre_rg}" > /dev/null 2>&1; echo $?) if [[ "$group_show_result" != "0" ]]; then echo "Resource group ${core_tre_rg} not found - skipping destroy" diff --git a/devops/scripts/key_vault_list.sh b/devops/scripts/key_vault_list.sh deleted file mode 100755 index faa1aa9384..0000000000 --- a/devops/scripts/key_vault_list.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -if [[ -z ${TRE_ID:-} ]]; then - echo "TRE_ID environment variable must be set." - exit 1 -fi - -echo "DEBUG: Check keyvault and secrets exist" - -echo "az keyvault show" -az keyvault show --name kv-${TRE_ID} - -echo "az keyvault secret list" -az keyvault secret list --vault-name kv-${TRE_ID} - -echo "az keyvault secret list-deleted" -az keyvault secret list-deleted --vault-name kv-${TRE_ID} diff --git a/devops/scripts/kv_add_network_exception.sh b/devops/scripts/kv_add_network_exception.sh new file mode 100755 index 0000000000..bc252cc656 --- /dev/null +++ b/devops/scripts/kv_add_network_exception.sh @@ -0,0 +1,131 @@ +#!/bin/bash + +# +# Add an IP exception to the Key Vault firewall for deployment, and remove on script exit +# The current machine's IP address is used, or $PUBLIC_DEPLOYMENT_IP_ADDRESS if set +# +# Note: Ensure you "source" this script, or else the EXIT trap won't fire at the right time +# + + +function kv_add_network_exception() { + + # set up variables + # + local KV_NAME + KV_NAME=$(get_kv_name) + + local MY_IP + MY_IP=$(get_my_ip) + + echo -e "\nAdding deployment network exception to key vault $KV_NAME..." + + # ensure kv exists + # + if ! does_kv_exist "$KV_NAME"; then + return 0 # don't cause outer sourced script to fail + fi + + # add keyvault network exception + # + az keyvault network-rule add --name "$KV_NAME" --ip-address "$MY_IP" --output none + + local ATTEMPT=1 + local MAX_ATTEMPTS=10 + + while true; do + + if KV_OUTPUT=$(az keyvault secret list --vault-name "$KV_NAME" --query '[].name' --output tsv 2>&1); then + echo -e " Keyvault $KV_NAME is now accessible\n" + break + fi + + if [ $ATTEMPT -eq $MAX_ATTEMPTS ]; then + echo -e "Could not add deployment network exception for $KV_NAME" + echo -e "Unable to access keyvault $KV_NAME after $ATTEMPT/$MAX_ATTEMPTS.\n" + echo -e "$KV_OUTPUT\n" + + exit 1 + fi + + echo " Unable to access keyvault $KV_NAME after $ATTEMPT/$MAX_ATTEMPTS. Waiting for network rules to take effect." + sleep 5 + ((ATTEMPT++)) + + done + +} + +function kv_remove_network_exception() { + + # set up variables + # + local KV_NAME + KV_NAME=$(get_kv_name) + + local MY_IP + MY_IP=$(get_my_ip) + + echo -e "\nRemoving deployment network exception to key vault $KV_NAME..." + + # ensure kv exists + # + if ! does_kv_exist "$KV_NAME"; then + return 0 # don't cause outer sourced script to fail + fi + + # remove keyvault network exception + # + az keyvault network-rule remove --name "$KV_NAME" --ip-address "$MY_IP" --output none + echo -e " Deployment network exception removed\n" +} + + +function get_kv_name() { + + local TRE_ID_LOCAL="${TRE_ID:-}" + + if [[ -z "$TRE_ID_LOCAL" ]]; then + if [[ "${core_tre_rg:-}" == rg-* ]]; then # TRE_ID may not be available when called from destroy_env_no_terraform.sh + TRE_ID_LOCAL="${core_tre_rg#rg-}" + fi + fi + + if [[ -z "$TRE_ID_LOCAL" ]]; then + echo -e "Could not add/remove keyvault deployment network exception: TRE_ID is not set\nExiting...\n" + exit 1 + fi + + echo "kv-${TRE_ID_LOCAL}" +} + +function get_my_ip() { + + local MY_IP="${PUBLIC_DEPLOYMENT_IP_ADDRESS:-}" + + if [[ -z "$MY_IP" ]]; then + MY_IP=$(curl -s "ipecho.net/plain"; echo) + fi + + echo "$MY_IP" +} + + +function does_kv_exist() { + + KV_NAME=$1 + + if [[ -z "$(az keyvault list --query "[?name=='$KV_NAME'].id" --output tsv)" ]]; then + echo -e " Core key vault $KV_NAME not found\n" + return 1 + fi + + return 0 +} + + +# setup the trap to remove network exception on exit +trap kv_remove_network_exception EXIT + +# now add the network exception +kv_add_network_exception "$@" diff --git a/devops/scripts/set_contributor_sp_secrets.sh b/devops/scripts/set_contributor_sp_secrets.sh deleted file mode 100755 index 95a07da877..0000000000 --- a/devops/scripts/set_contributor_sp_secrets.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -set -e - -# This script adds the client (app) ID and the client secret (app password) of the service principal used for deploying -# resources (workspaces and workspace services) to Key Vault. -# -# Running the script requires that Azure CLI login has been done with the credentials that have privileges to access -# the Key Vault. -# -# Required environment variables: -# -# - TRE_ID - The TRE ID, used to deduce the Key Vault name -# - ARM_SUBSCRIPTION_ID - The Azure subscription ID -# - RESOURCE_PROCESSOR_CLIENT_ID - The client ID of the service principal -# - RESOURCE_PROCESSOR_CLIENT_SECRET - The client secret of the service principal -# - -echo -e "\n\e[34m»»» 🤖 \e[96mCreating (or updating) service principal ID and secret to Key Vault\e[0m..." -key_vault_name="kv-$TRE_ID" -az account set --subscription $ARM_SUBSCRIPTION_ID -az keyvault secret set --name deployment-processor-azure-client-id --vault-name $key_vault_name --value $RESOURCE_PROCESSOR_CLIENT_ID -az keyvault secret set --name deployment-processor-azure-client-secret --vault-name $key_vault_name --value $RESOURCE_PROCESSOR_CLIENT_SECRET > /dev/null diff --git a/devops/scripts/setup_local_debugging.sh b/devops/scripts/setup_local_debugging.sh index 4cb2dbdb55..2bf70a63d0 100755 --- a/devops/scripts/setup_local_debugging.sh +++ b/devops/scripts/setup_local_debugging.sh @@ -91,6 +91,19 @@ az role assignment create \ --assignee "${LOGGED_IN_OBJECT_ID}" \ --scope "${STATE_STORE_RESOURCE_ID}" +ROLE_DEFINITION_ID=$(az cosmosdb sql role definition list \ + --resource-group "${RESOURCE_GROUP_NAME}" \ + --account-name "${COSMOSDB_ACCOUNT_NAME}" \ + --query "[?roleName=='Cosmos DB Built-in Data Contributor'].id" \ + --output tsv) + +az cosmosdb sql role assignment create \ + --resource-group "${RESOURCE_GROUP_NAME}" \ + --account-name "${COSMOSDB_ACCOUNT_NAME}" \ + --role-definition-id "${ROLE_DEFINITION_ID}" \ + --principal-id "${LOGGED_IN_OBJECT_ID}" \ + --scope "${STATE_STORE_RESOURCE_ID}" + az role assignment create \ --role "Contributor" \ --assignee "${LOGGED_IN_OBJECT_ID}" \ diff --git a/docs/tre-developers/release.md b/docs/tre-developers/release.md index 19aeb12866..40a431eea5 100644 --- a/docs/tre-developers/release.md +++ b/docs/tre-developers/release.md @@ -21,6 +21,7 @@ The process follows these steps: 5. Include a final line with a link to the full changelog similar to this: **Full Changelog**: https://github.com/microsoft/AzureTRE/compare/v0.9.1...v0.9.2 + 7. Update [AzureTRE-Deployment](https://github.com/microsoft/AzureTRE-Deployment). The procedure may vary depending on the level of changes introduced in the new version but should include the following steps: 1. Update the tag used in [devcontainer.json](https://github.com/microsoft/AzureTRE-Deployment/blob/main/.devcontainer/devcontainer.json). 2. Rebuild the container. diff --git a/docs/tre-developers/ui.md b/docs/tre-developers/ui.md index e9b4a04441..872a883d5f 100644 --- a/docs/tre-developers/ui.md +++ b/docs/tre-developers/ui.md @@ -4,7 +4,7 @@ This project contains a React-based web UI which covers the core aspects of a TR ## Chosen UI Stack + Components The UI is built upon several popular web frameworks: -- React v18 (created via create-react-app, with all build configurations left as defaults) +- React v18 (with Vite) - Typescript - React Router v6 for client side routing - Fluent UI [Fluent UI Docs](https://developer.microsoft.com/en-us/fluentui#/controls/web) @@ -54,4 +54,53 @@ The UI is deployed as part of the `tre-deploy` make target (unless you set `depl To re-deploy _just_ the UI (after an initial deploy), run `make build-and-deploy-ui` from the root of the dev container. This will: - Use the environment variables from your deployment to create a `config.json` file for the UI - Build the source code, via `yarn build` -- Deploy the code to Azure blob storage, where it will be statically served behind the App Gateway that also fronts the APi. +- Deploy the code to Azure blob storage, where it will be statically served behind the App Gateway that also fronts the API. + +## Run the UI +- Ensure `deploy_ui=false` is not set in your `./config.yaml` file +- In the root of the repo, run `make tre-deploy`. This will provision the necessary resources in Azure, build and deploy the UI to Azure blob storage, behind the App Gateway used for the API. The deployment process will also create the necessary `config.json`, using the `config.source.json` as a template. +- In Microsoft Entra ID, locate the TRE Client Apps app (possibly called Swagger App). In the Authentication section add reply URIs for: + - `http://localhost:3000` (if wanting to run locally) + - Your deployed App Url - `https://{TRE_ID}.{LOCATION}.cloudapp.azure.com`. + +At this point you should be able to navigate to the web app in Azure, log in, and see your workspaces. + +## Available Scripts + +In the UI directory, you can run: + +### `yarn start` + +Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.
+You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.
+ +### `yarn run build` + +Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.
+Your app is ready to be deployed! + +### `yarn run serve` + +Serves the production build from the `build` folder.
+ +### `yarn run test:coverage` + +Runs the tests and generates a coverage report.
+ +### `yarn lint` + +Runs the linter on the project.
+ +### `yarn format` + +Runs the formatter on the project.
diff --git a/resource_processor/_version.py b/resource_processor/_version.py index fee46bd8ce..def467e071 100644 --- a/resource_processor/_version.py +++ b/resource_processor/_version.py @@ -1 +1 @@ -__version__ = "0.11.1" +__version__ = "0.12.1" diff --git a/resource_processor/helpers/__init__.py b/resource_processor/helpers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/resource_processor/resources/commands.py b/resource_processor/helpers/commands.py similarity index 89% rename from resource_processor/resources/commands.py rename to resource_processor/helpers/commands.py index 0111b52358..59c0eaea82 100644 --- a/resource_processor/resources/commands.py +++ b/resource_processor/helpers/commands.py @@ -4,14 +4,13 @@ import logging from urllib.parse import urlparse -from resources.helpers import get_installation_id from shared.logging import logger, shell_output_logger def azure_login_command(config): set_cloud_command = f"az cloud set --name {config['azure_environment']} >/dev/null " - if config["vmss_msi_id"]: + if config.get("vmss_msi_id"): # Use the Managed Identity when in VMSS context login_command = f"az login --identity -u {config['vmss_msi_id']} >/dev/null " @@ -23,7 +22,7 @@ def azure_login_command(config): def apply_porter_credentials_sets_command(config): - if config["vmss_msi_id"]: + if config.get("vmss_msi_id"): # Use the Managed Identity when in VMSS context porter_credential_sets = "porter credentials apply vmss_porter/arm_auth_local_debugging.json >/dev/null 2>&1 && porter credentials apply vmss_porter/aad_auth.json >/dev/null 2>&1" @@ -80,25 +79,31 @@ async def build_porter_command(config, msg_body, custom_action=False): val_base64_bytes = base64.b64encode(val_bytes) parameter_value = val_base64_bytes.decode("ascii") - porter_parameters = porter_parameters + f" --param {parameter_name}=\"{parameter_value}\"" + porter_parameters += f" --param {parameter_name}=\"{parameter_value}\"" - installation_id = get_installation_id(msg_body) + installation_id = msg_body['id'] command_line = [f"porter" # If a custom action (i.e. not install, uninstall, upgrade) we need to use 'invoke' - f"{' invoke --action' if custom_action else ''}" - f" {msg_body['action']} \"{installation_id}\"" - f" --reference {config['registry_server']}/{msg_body['name']}:v{msg_body['version']}" - f" {porter_parameters} --force" - f" --credential-set arm_auth" - f" --credential-set aad_auth" + f"{' invoke --action' if custom_action else ''} " + f"{msg_body['action']} \"{installation_id}\" " + f"--reference {config['registry_server']}/{msg_body['name']}:v{msg_body['version']}" + f"{porter_parameters} " + f"--force " + f"--credential-set arm_auth " + f"--credential-set aad_auth " ] + if msg_body['action'] == 'upgrade': + command_line[0] = command_line[0] + "--force-upgrade " + + command_line[0] = command_line[0].strip() + return command_line async def build_porter_command_for_outputs(msg_body): - installation_id = get_installation_id(msg_body) + installation_id = msg_body['id'] command_line = [f"porter installations output list --installation {installation_id} --output json"] return command_line diff --git a/resource_processor/resources/httpserver.py b/resource_processor/helpers/httpserver.py similarity index 100% rename from resource_processor/resources/httpserver.py rename to resource_processor/helpers/httpserver.py diff --git a/resource_processor/resources/statuses.py b/resource_processor/helpers/statuses.py similarity index 96% rename from resource_processor/resources/statuses.py rename to resource_processor/helpers/statuses.py index 952dcef24b..2e1941f482 100644 --- a/resource_processor/resources/statuses.py +++ b/resource_processor/helpers/statuses.py @@ -1,5 +1,5 @@ from collections import defaultdict -from resources import strings +from helpers import strings # Specify pass and fail status strings so we can return the right statuses to the api depending on the action type (with a default of custom action) diff --git a/resource_processor/resources/strings.py b/resource_processor/helpers/strings.py similarity index 100% rename from resource_processor/resources/strings.py rename to resource_processor/helpers/strings.py diff --git a/resource_processor/resources/helpers.py b/resource_processor/resources/helpers.py deleted file mode 100644 index 98ef4d2e0d..0000000000 --- a/resource_processor/resources/helpers.py +++ /dev/null @@ -1,5 +0,0 @@ -def get_installation_id(msg_body): - """ - This is used to identify each bundle install within the porter state store. - """ - return msg_body['id'] diff --git a/resource_processor/tests_rp/test_commands.py b/resource_processor/tests_rp/test_commands.py new file mode 100644 index 0000000000..bb2f9f20e0 --- /dev/null +++ b/resource_processor/tests_rp/test_commands.py @@ -0,0 +1,94 @@ +import json +import pytest +from unittest.mock import patch, AsyncMock +from helpers.commands import azure_login_command, apply_porter_credentials_sets_command, azure_acr_login_command, build_porter_command, build_porter_command_for_outputs, get_porter_parameter_keys + + +@pytest.fixture +def mock_get_porter_parameter_keys(): + with patch("helpers.commands.get_porter_parameter_keys", new_callable=AsyncMock) as mock: + yield mock + + +@pytest.mark.parametrize("config, expected_command", [ + ({"azure_environment": "AzureCloud", "vmss_msi_id": "msi_id"}, "az cloud set --name AzureCloud >/dev/null && az login --identity -u msi_id >/dev/null "), + ({"azure_environment": "AzureCloud", "arm_client_id": "client_id", "arm_client_secret": "client_secret", "arm_tenant_id": "tenant_id"}, "az cloud set --name AzureCloud >/dev/null && az login --service-principal --username client_id --password client_secret --tenant tenant_id >/dev/null") +]) +def test_azure_login_command(config, expected_command): + """Test azure_login_command function.""" + assert azure_login_command(config) == expected_command + + +@pytest.mark.parametrize("config, expected_command", [ + ({"vmss_msi_id": "msi_id"}, "porter credentials apply vmss_porter/arm_auth_local_debugging.json >/dev/null 2>&1 && porter credentials apply vmss_porter/aad_auth.json >/dev/null 2>&1"), + ({}, "porter credentials apply vmss_porter/arm_auth_local_debugging.json >/dev/null 2>&1 && porter credentials apply vmss_porter/aad_auth_local_debugging.json >/dev/null 2>&1") +]) +def test_apply_porter_credentials_sets_command(config, expected_command): + """Test apply_porter_credentials_sets_command function.""" + assert apply_porter_credentials_sets_command(config) == expected_command + + +@pytest.mark.parametrize("config, expected_command", [ + ({"registry_server": "myregistry.azurecr.io"}, "az acr login --name myregistry >/dev/null ") +]) +def test_azure_acr_login_command(config, expected_command): + """Test azure_acr_login_command function.""" + assert azure_acr_login_command(config) == expected_command + + +@pytest.mark.asyncio +async def test_build_porter_command(mock_get_porter_parameter_keys): + """Test build_porter_command function.""" + config = {"registry_server": "myregistry.azurecr.io"} + msg_body = {"id": "guid", "action": "install", "name": "mybundle", "version": "1.0.0", "parameters": {"param1": "value1"}} + mock_get_porter_parameter_keys.return_value = ["param1"] + + expected_command = [ + "porter install \"guid\" --reference myregistry.azurecr.io/mybundle:v1.0.0 --param param1=\"value1\" --force --credential-set arm_auth --credential-set aad_auth" + ] + + command = await build_porter_command(config, msg_body) + assert command == expected_command + + +@pytest.mark.asyncio +async def test_build_porter_command_for_upgrade(mock_get_porter_parameter_keys): + """Test build_porter_command function for upgrade action.""" + config = {"registry_server": "myregistry.azurecr.io"} + msg_body = {"id": "guid", "action": "upgrade", "name": "mybundle", "version": "1.0.0", "parameters": {"param1": "value1"}} + mock_get_porter_parameter_keys.return_value = ["param1"] + + expected_command = [ + "porter upgrade \"guid\" --reference myregistry.azurecr.io/mybundle:v1.0.0 --param param1=\"value1\" --force --credential-set arm_auth --credential-set aad_auth --force-upgrade" + ] + + command = await build_porter_command(config, msg_body) + assert command == expected_command + + +@pytest.mark.asyncio +async def test_build_porter_command_for_outputs(): + """Test build_porter_command_for_outputs function.""" + msg_body = {"id": "guid", "action": "install", "name": "mybundle", "version": "1.0.0"} + expected_command = ["porter installations output list --installation guid --output json"] + + command = await build_porter_command_for_outputs(msg_body) + assert command == expected_command + + +@pytest.mark.asyncio +@patch("helpers.commands.azure_login_command", return_value="az login command") +@patch("helpers.commands.azure_acr_login_command", return_value="az acr login command") +@patch("asyncio.create_subprocess_shell") +async def test_get_porter_parameter_keys(mock_create_subprocess_shell, mock_azure_acr_login_command, mock_azure_login_command): + """Test get_porter_parameter_keys function.""" + config = {"registry_server": "myregistry.azurecr.io", "porter_env": {}} + msg_body = {"name": "mybundle", "version": "1.0.0"} + mock_proc = AsyncMock() + mock_proc.communicate.return_value = (json.dumps({"parameters": [{"name": "param1"}]}).encode(), b"") + mock_create_subprocess_shell.return_value = mock_proc + + expected_keys = ["param1"] + + keys = await get_porter_parameter_keys(config, msg_body) + assert keys == expected_keys diff --git a/resource_processor/tests_rp/test_runner.py b/resource_processor/tests_rp/test_runner.py new file mode 100644 index 0000000000..7c1dc6d2e4 --- /dev/null +++ b/resource_processor/tests_rp/test_runner.py @@ -0,0 +1,284 @@ +import json +from unittest.mock import patch, AsyncMock, Mock +import pytest +from resource_processor.vmss_porter.runner import ( + set_up_config, receive_message, invoke_porter_action, get_porter_outputs, check_runners, runner +) +from azure.servicebus.aio import ServiceBusClient +from azure.servicebus import ServiceBusSessionFilter + + +@pytest.fixture +def mock_service_bus_client(): + with patch("resource_processor.vmss_porter.runner.ServiceBusClient") as mock: + yield mock + + +@pytest.fixture +def mock_default_credential(): + with patch("resource_processor.vmss_porter.runner.default_credentials") as mock: + yield mock + + +@pytest.fixture +def mock_auto_lock_renewer(): + with patch("resource_processor.vmss_porter.runner.AutoLockRenewer") as mock: + yield mock + + +@pytest.fixture +def mock_logger(): + with patch("resource_processor.vmss_porter.runner.logger") as mock: + yield mock + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.get_config", return_value={"resource_request_queue": "test_queue", "service_bus_namespace": "test_namespace", "vmss_msi_id": "test_msi_id", "porter_env": {}}) +async def test_set_up_config(mock_get_config): + """Test setting up configuration.""" + config = set_up_config() + assert config == {"resource_request_queue": "test_queue", "service_bus_namespace": "test_namespace", "vmss_msi_id": "test_msi_id", "porter_env": {}} + + +async def setup_service_bus_client_and_credential(mock_service_bus_client, mock_default_credential, msi_id): + mock_credential = AsyncMock() + mock_default_credential.return_value.__aenter__.return_value = mock_credential + mock_service_bus_client_instance = mock_service_bus_client.return_value + return mock_service_bus_client_instance, mock_credential + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.receive_message") +async def test_runner(mock_receive_message, mock_service_bus_client, mock_default_credential): + """Test runner with valid MSI ID.""" + mock_service_bus_client_instance, mock_credential = await setup_service_bus_client_and_credential(mock_service_bus_client, mock_default_credential, 'test_msi_id') + + config = {"vmss_msi_id": "test_msi_id", "service_bus_namespace": "test_namespace"} + + await runner(0, config) + + mock_default_credential.assert_called_once_with('test_msi_id') + mock_service_bus_client.assert_called_once_with("test_namespace", mock_credential) + mock_receive_message.assert_called_once_with(mock_service_bus_client_instance, config) + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.receive_message") +async def test_runner_no_msi_id(mock_receive_message, mock_service_bus_client, mock_default_credential): + """Test runner with no MSI ID.""" + mock_service_bus_client_instance, mock_credential = await setup_service_bus_client_and_credential(mock_service_bus_client, mock_default_credential, None) + + config = {"vmss_msi_id": None, "service_bus_namespace": "test_namespace"} + + await runner(0, config) + + mock_default_credential.assert_called_once_with(None) + mock_service_bus_client.assert_called_once_with("test_namespace", mock_credential) + mock_receive_message.assert_called_once_with(mock_service_bus_client_instance, config) + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.receive_message") +async def test_runner_exception(mock_receive_message, mock_service_bus_client, mock_default_credential): + """Test runner with an exception.""" + mock_service_bus_client_instance, mock_credential = await setup_service_bus_client_and_credential(mock_service_bus_client, mock_default_credential, 'test_msi_id') + mock_receive_message.side_effect = Exception("Test Exception") + + config = {"vmss_msi_id": "test_msi_id", "service_bus_namespace": "test_namespace"} + + with pytest.raises(Exception, match="Test Exception"): + await runner(0, config) + + mock_default_credential.assert_called_once_with('test_msi_id') + mock_service_bus_client.assert_called_once_with("test_namespace", mock_credential) + mock_receive_message.assert_called_once_with(mock_service_bus_client_instance, config) + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.invoke_porter_action", return_value=True) +async def test_receive_message(mock_invoke_porter_action, mock_service_bus_client, mock_auto_lock_renewer): + mock_service_bus_client_instance = mock_service_bus_client.return_value + mock_auto_lock_renewer.return_value = AsyncMock() + + mock_receiver = AsyncMock() + mock_receiver.__aenter__.return_value = mock_receiver + mock_receiver.__aexit__.return_value = None + mock_receiver.session.session_id = "test_session_id" + mock_receiver.__aiter__.return_value = [AsyncMock()] + mock_receiver.__aiter__.return_value[0] = json.dumps({"id": "test_id", "action": "install", "stepId": "test_step_id", "operationId": "test_operation_id"}) + + mock_service_bus_client_instance.get_queue_receiver.return_value.__aenter__.return_value = mock_receiver + + run_once = Mock(side_effect=[True, False]) + + config = {"resource_request_queue": "test_queue"} + + await receive_message(mock_service_bus_client_instance, config, keep_running=run_once) + mock_receiver.complete_message.assert_called_once() + mock_service_bus_client_instance.get_queue_receiver.assert_called_once_with(queue_name="test_queue", max_wait_time=1, session_id=ServiceBusSessionFilter.NEXT_AVAILABLE) + + +@pytest.mark.asyncio +async def test_receive_message_unknown_exception(mock_auto_lock_renewer, mock_service_bus_client, mock_logger): + """Test receiving a message with an unknown exception.""" + mock_service_bus_client_instance = mock_service_bus_client.return_value + mock_auto_lock_renewer.return_value = AsyncMock() + + mock_receiver = AsyncMock() + mock_receiver.__aenter__.return_value = mock_receiver + mock_receiver.__aexit__.return_value = None + mock_receiver.session.session_id = "test_session_id" + mock_receiver.__aiter__.return_value = [AsyncMock()] + mock_receiver.__aiter__.return_value[0] = json.dumps({"id": "test_id", "action": "install", "stepId": "test_step_id", "operationId": "test_operation_id"}) + + mock_service_bus_client_instance.get_queue_receiver.return_value.__aenter__.return_value = mock_receiver + + run_once = Mock(side_effect=[True, False]) + + config = {"resource_request_queue": "test_queue"} + + with patch("resource_processor.vmss_porter.runner.receive_message", side_effect=Exception("Test Exception")): + await receive_message(mock_service_bus_client_instance, config, keep_running=run_once) + mock_logger.exception.assert_any_call("Unknown exception. Will retry...") + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter install"]) +@patch("resource_processor.vmss_porter.runner.run_porter", return_value=(0, "stdout", "stderr")) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter action.""" + mock_sb_sender = AsyncMock() + mock_service_bus_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "install", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_service_bus_client, config) + + assert result is True + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter install"]) +@patch("resource_processor.vmss_porter.runner.run_porter", return_value=(1, "", "error")) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action_failure(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter action with failure.""" + mock_sb_client = AsyncMock(spec=ServiceBusClient) + mock_sb_sender = AsyncMock() + mock_sb_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "install", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_sb_client, config) + + assert result is False + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter install"]) +@patch("resource_processor.vmss_porter.runner.run_porter", side_effect=[(1, "", "could not find installation"), (0, "", "")]) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action_upgrade_failure_install_success(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter action with upgrade failure and install success.""" + mock_sb_client = AsyncMock(spec=ServiceBusClient) + mock_sb_sender = AsyncMock() + mock_sb_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "upgrade", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_sb_client, config) + + assert result is True + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter install"]) +@patch("resource_processor.vmss_porter.runner.run_porter", side_effect=[(1, "", "could not find installation"), (1, "", "installation failed")]) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action_upgrade_failure_install_failure(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter action with upgrade and install failure.""" + mock_sb_client = AsyncMock(spec=ServiceBusClient) + mock_sb_sender = AsyncMock() + mock_sb_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "upgrade", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_sb_client, config) + + assert result is False + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter install"]) +@patch("resource_processor.vmss_porter.runner.run_porter", return_value=(1, "", "could not find installation")) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action_uninstall_failure(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter action with uninstall failure.""" + mock_sb_client = AsyncMock(spec=ServiceBusClient) + mock_sb_sender = AsyncMock() + mock_sb_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "uninstall", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_sb_client, config) + + assert result is True + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command", return_value=["porter custom-action"]) +@patch("resource_processor.vmss_porter.runner.run_porter", return_value=(0, "stdout", "stderr")) +@patch("resource_processor.vmss_porter.runner.service_bus_message_generator", return_value="test_message") +async def test_invoke_porter_action_custom_action(mock_service_bus_message_generator, mock_run_porter, mock_build_porter_command, mock_service_bus_client): + """Test invoking a porter custom action.""" + mock_sb_client = AsyncMock(spec=ServiceBusClient) + mock_sb_sender = AsyncMock() + mock_sb_client.get_queue_sender.return_value = mock_sb_sender + + config = {"deployment_status_queue": "test_queue"} + msg_body = {"id": "test_id", "action": "custom-action", "stepId": "test_step_id", "operationId": "test_operation_id"} + + result = await invoke_porter_action(msg_body, mock_sb_client, config) + + assert result is True + mock_sb_sender.send_messages.assert_called() + + +@pytest.mark.asyncio +@patch("resource_processor.vmss_porter.runner.build_porter_command_for_outputs", return_value=["porter installations output list"]) +@patch("resource_processor.vmss_porter.runner.run_porter", return_value=(0, json.dumps([{"name": "output1", "value": "value1"}]), "stderr")) +async def test_get_porter_outputs(mock_run_porter, mock_build_porter_command_for_outputs): + """Test getting porter outputs.""" + config = {} + msg_body = {"id": "test_id", "action": "install"} + + success, outputs = await get_porter_outputs(msg_body, config) + + assert success is True + assert outputs == [{"name": "output1", "value": "value1"}] + + +@pytest.mark.asyncio +@patch("asyncio.sleep", new_callable=AsyncMock) +async def test_check_runners(_): + """Test checking runners.""" + mock_process = Mock() + mock_process.is_alive.return_value = False + processes = [mock_process] + mock_httpserver = AsyncMock() + + run_once = Mock(side_effect=[True, False]) + + await check_runners(processes, mock_httpserver, keep_running=run_once) + mock_httpserver.kill.assert_called_once() diff --git a/resource_processor/vmss_porter/runner.py b/resource_processor/vmss_porter/runner.py index 3de4ac06fe..6f6d6e21e1 100644 --- a/resource_processor/vmss_porter/runner.py +++ b/resource_processor/vmss_porter/runner.py @@ -4,14 +4,13 @@ import asyncio import logging import sys -from resources.commands import azure_acr_login_command, azure_login_command, build_porter_command, build_porter_command_for_outputs, apply_porter_credentials_sets_command +from helpers.commands import azure_acr_login_command, azure_login_command, build_porter_command, build_porter_command_for_outputs, apply_porter_credentials_sets_command from shared.config import get_config -from resources.helpers import get_installation_id -from resources.httpserver import start_server +from helpers.httpserver import start_server from shared.logging import initialize_logging, logger, shell_output_logger, tracer from shared.config import VERSION -from resources import statuses +from helpers import statuses from contextlib import asynccontextmanager from azure.servicebus import ServiceBusMessage, NEXT_AVAILABLE_SESSION from azure.servicebus.exceptions import OperationTimeoutError, ServiceBusConnectionError @@ -38,7 +37,7 @@ async def default_credentials(msi_id): await credential.close() -async def receive_message(service_bus_client, config: dict): +async def receive_message(service_bus_client, config: dict, keep_running=lambda: True): """ This method is run per process. Each process will connect to service bus and try to establish a session. If messages are there, the process will continue to receive all the messages associated with that session. @@ -46,7 +45,7 @@ async def receive_message(service_bus_client, config: dict): """ q_name = config["resource_request_queue"] - while True: + while keep_running(): try: logger.info("Looking for new session...") # max_wait_time=1 -> don't hold the session open after processing of the message has finished @@ -94,6 +93,7 @@ async def receive_message(service_bus_client, config: dict): except Exception: # Catch all other exceptions, log them via .exception to get the stack trace, sleep, and reconnect + logger.exception("Unknown exception. Will retry...") @@ -135,7 +135,7 @@ def service_bus_message_generator(sb_message: dict, status: str, deployment_mess """ Generate a resource request message """ - installation_id = get_installation_id(sb_message) + installation_id = sb_message["id"] message_dict = { "operationId": sb_message["operationId"], "stepId": sb_message["stepId"], @@ -156,7 +156,7 @@ async def invoke_porter_action(msg_body: dict, sb_client: ServiceBusClient, conf Handle resource message by invoking specified porter action (i.e. install, uninstall) """ - installation_id = get_installation_id(msg_body) + installation_id = msg_body["id"] action = msg_body["action"] logger.info(f"{action} action starting for {installation_id}...") sb_sender = sb_client.get_queue_sender(queue_name=config["deployment_status_queue"]) @@ -173,13 +173,25 @@ async def invoke_porter_action(msg_body: dict, sb_client: ServiceBusClient, conf logger.debug("Starting to run porter execution command...") returncode, _, err = await run_porter(porter_command, config) logger.debug("Finished running porter execution command.") - action_completed_without_error = True + + action_completed_without_error = False + + if returncode == 0: + action_completed_without_error = True # Handle command output if returncode != 0 and err is not None: error_message = "Error message: " + " ".join(err.split('\n')) + "; Command executed: " + " ".join(porter_command) action_completed_without_error = False + if "upgrade" == action and ("could not find installation" in err or "The installation cannot be upgraded, because it is not installed." in err): + logger.warning("Upgrade failed, attempting install...") + msg_body['action'] = "install" + porter_command = await build_porter_command(config, msg_body, False) + returncode, _, err = await run_porter(porter_command, config) + if returncode == 0: + action_completed_without_error = True + if "uninstall" == action and "could not find installation" in err: logger.warning("The installation doesn't exist. Treating as a successful action to allow the flow to proceed.") action_completed_without_error = True @@ -227,7 +239,8 @@ async def get_porter_outputs(msg_body: dict, config: dict): if returncode != 0: error_message = "Error context message = " + " ".join(err.split('\n')) - logger.info(f"{get_installation_id(msg_body)}: Failed to get outputs with error = {error_message}") + installation_id = msg_body["id"] + logger.info(f"{installation_id}: Failed to get outputs with error = {error_message}") return False, {} else: outputs_json = {} @@ -253,10 +266,10 @@ async def runner(process_number: int, config: dict): await receive_message(service_bus_client, config) -async def check_runners(processes: list, httpserver: Process): +async def check_runners(processes: list, httpserver: Process, keep_running=lambda: True): logger.info("Starting runners check...") - while True: + while keep_running(): await asyncio.sleep(30) if all(not process.is_alive() for process in processes): logger.error("All runner processes have failed!") diff --git a/templates/shared_services/admin-vm/parameters.json b/templates/shared_services/admin-vm/parameters.json index f133067bf8..de37ff3528 100755 --- a/templates/shared_services/admin-vm/parameters.json +++ b/templates/shared_services/admin-vm/parameters.json @@ -57,6 +57,18 @@ "source": { "env": "KEY_STORE_ID" } + }, + { + "name": "os_image", + "source": { + "env": "OS_IMAGE" + } + }, + { + "name": "image_gallery_id", + "source": { + "env": "IMAGE_GALLERY_ID" + } } ] } diff --git a/templates/shared_services/admin-vm/porter.yaml b/templates/shared_services/admin-vm/porter.yaml index 8a967f7b5a..204b08dff1 100644 --- a/templates/shared_services/admin-vm/porter.yaml +++ b/templates/shared_services/admin-vm/porter.yaml @@ -1,11 +1,27 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-admin-vm -version: 0.5.2 +version: 0.5.3 description: "An admin vm shared service" dockerfile: Dockerfile.tmpl registry: azuretre +custom: + image_options: + "Windows 11": + source_image_reference: + publisher: MicrosoftWindowsDesktop + offer: windows-11 + sku: win11-24h2-pro + version: latest + secure_boot_enabled: true + vtpm_enabled: true + # For information on using custom images, see README.me in the guacamole/user-resources folder + # "Custom Image From Gallery": + # source_image_name: your-image + # secure_boot_enabled: false + # vtpm_enabled: false + credentials: - name: azure_tenant_id env: ARM_TENANT_ID @@ -50,6 +66,13 @@ parameters: - name: key_store_id type: string default: "" + - name: os_image + type: string + default: "Windows 11" + - name: image_gallery_id + type: string + description: Azure resource ID for the compute image gallery to pull images from (if specifying custom images by name) + default: "" mixins: - terraform: @@ -64,6 +87,8 @@ install: admin_jumpbox_vm_sku: ${ bundle.parameters.admin_jumpbox_vm_sku } enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } + image_gallery_id: ${ bundle.parameters.image_gallery_id } + image: ${ bundle.parameters.os_image } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -81,6 +106,8 @@ upgrade: admin_jumpbox_vm_sku: ${ bundle.parameters.admin_jumpbox_vm_sku } enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } + image_gallery_id: ${ bundle.parameters.image_gallery_id } + image: ${ bundle.parameters.os_image } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -98,6 +125,8 @@ uninstall: admin_jumpbox_vm_sku: ${ bundle.parameters.admin_jumpbox_vm_sku } enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } + image_gallery_id: ${ bundle.parameters.image_gallery_id } + image: ${ bundle.parameters.os_image } backendConfig: use_azuread_auth: "true" use_oidc: "true" diff --git a/templates/shared_services/admin-vm/template_schema.json b/templates/shared_services/admin-vm/template_schema.json index d644d49be7..e0f08f17fd 100644 --- a/templates/shared_services/admin-vm/template_schema.json +++ b/templates/shared_services/admin-vm/template_schema.json @@ -6,6 +6,16 @@ "description": "Provides VM in the core network", "required": [], "properties": { + "os_image": { + "$id": "#/properties/os_image", + "type": "string", + "title": "Windows image", + "description": "Select Windows image to use for VM", + "enum": [ + "Windows 11" + ], + "default": "Windows 11" + }, "admin_jumpbox_vm_sku": { "$id": "#/properties/admin_jumpbox_vm_sku", "type": "string", diff --git a/templates/shared_services/admin-vm/terraform/admin-jumpbox.tf b/templates/shared_services/admin-vm/terraform/admin-jumpbox.tf index 97919f81d1..ce343e48f4 100644 --- a/templates/shared_services/admin-vm/terraform/admin-jumpbox.tf +++ b/templates/shared_services/admin-vm/terraform/admin-jumpbox.tf @@ -37,14 +37,19 @@ resource "azurerm_windows_virtual_machine" "jumpbox" { admin_password = random_password.password.result tags = local.tre_shared_service_tags encryption_at_host_enabled = true - secure_boot_enabled = true - vtpm_enabled = true + secure_boot_enabled = local.secure_boot_enabled + vtpm_enabled = local.vtpm_enabled - source_image_reference { - publisher = "MicrosoftWindowsDesktop" - offer = "windows-11" - sku = "win11-24h2-pro" - version = "latest" + # set source_image_id/reference depending on the config for the selected image + source_image_id = local.selected_image_source_id + dynamic "source_image_reference" { + for_each = local.selected_image_source_refs + content { + publisher = source_image_reference.value["publisher"] + offer = source_image_reference.value["offer"] + sku = source_image_reference.value["sku"] + version = source_image_reference.value["version"] + } } os_disk { diff --git a/templates/shared_services/admin-vm/terraform/locals.tf b/templates/shared_services/admin-vm/terraform/locals.tf index 84c7fb8dfb..a6f25ed263 100644 --- a/templates/shared_services/admin-vm/terraform/locals.tf +++ b/templates/shared_services/admin-vm/terraform/locals.tf @@ -8,4 +8,16 @@ locals { } cmk_name = "tre-encryption-${var.tre_id}" encryption_identity_name = "id-encryption-${var.tre_id}" + + # Load image details from porter.yaml + porter_yaml = yamldecode(file("${path.module}/../porter.yaml")) + image_details = local.porter_yaml["custom"]["image_options"] + + # Create local variables to support the VM resource + selected_image = local.image_details[var.image] + # selected_image_source_refs is an array to enable easy use of a dynamic block + selected_image_source_refs = lookup(local.selected_image, "source_image_reference", null) == null ? [] : [local.selected_image.source_image_reference] + selected_image_source_id = lookup(local.selected_image, "source_image_name", null) == null ? null : "${var.image_gallery_id}/images/${local.selected_image.source_image_name}" + secure_boot_enabled = lookup(local.selected_image, "secure_boot_enabled", false) + vtpm_enabled = lookup(local.selected_image, "vtpm_enabled", false) } diff --git a/templates/shared_services/admin-vm/terraform/variables.tf b/templates/shared_services/admin-vm/terraform/variables.tf index 69ba1e51b3..f11048bf29 100644 --- a/templates/shared_services/admin-vm/terraform/variables.tf +++ b/templates/shared_services/admin-vm/terraform/variables.tf @@ -20,3 +20,11 @@ variable "enable_cmk_encryption" { variable "key_store_id" { type = string } + +variable "image_gallery_id" { + type = string +} + +variable "image" { + type = string +} diff --git a/templates/shared_services/certs/porter.yaml b/templates/shared_services/certs/porter.yaml index c334eda592..ede2be3478 100755 --- a/templates/shared_services/certs/porter.yaml +++ b/templates/shared_services/certs/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-certs -version: 0.7.3 +version: 0.7.4 description: "An Azure TRE shared service to generate certificates for a specified internal domain using Letsencrypt" registry: azuretre dockerfile: Dockerfile.tmpl @@ -88,6 +88,7 @@ install: - name: storage_account_name - name: resource_group_name - name: keyvault_name + - name: password_name - az: description: "Set Azure Cloud Environment" arguments: @@ -114,6 +115,7 @@ install: resource_group_name: ${ bundle.outputs.resource_group_name } keyvault_name: ${ bundle.outputs.keyvault_name } cert_name: ${ bundle.parameters.cert_name } + password_name: ${ bundle.outputs.password_name } - az: description: "Stop application gateway" arguments: @@ -167,6 +169,7 @@ renew: - name: storage_account_name - name: resource_group_name - name: keyvault_name + - name: password_name - az: description: "Set Azure Cloud Environment" arguments: @@ -202,6 +205,7 @@ renew: resource_group_name: ${ bundle.outputs.resource_group_name } keyvault_name: ${ bundle.outputs.keyvault_name } cert_name: ${ bundle.parameters.cert_name } + password_name: ${ bundle.outputs.password_name } - az: description: "Stop application gateway" arguments: diff --git a/templates/shared_services/certs/scripts/letsencrypt.sh b/templates/shared_services/certs/scripts/letsencrypt.sh index 4339990f43..bd88397215 100755 --- a/templates/shared_services/certs/scripts/letsencrypt.sh +++ b/templates/shared_services/certs/scripts/letsencrypt.sh @@ -29,6 +29,10 @@ while [ "$1" != "" ]; do shift cert_name=$1 ;; + --password_name) + shift + password_name=$1 + ;; *) echo "Unexpected argument: '$1'" usage @@ -122,8 +126,8 @@ sid=$(az keyvault certificate import \ --password "${CERT_PASSWORD}" \ | jq -r '.sid') -echo "Saving certificate password to KV with key ${cert_name}-password" -az keyvault secret set --name "${cert_name}"-password \ +echo "Saving certificate password to KV with key ${password_name}" +az keyvault secret set --name "$password_name" \ --vault-name "${keyvault_name}" \ --value "${CERT_PASSWORD}" diff --git a/templates/shared_services/certs/terraform/certificate.tf b/templates/shared_services/certs/terraform/certificate.tf index 0a825c491d..2ceb183ab5 100644 --- a/templates/shared_services/certs/terraform/certificate.tf +++ b/templates/shared_services/certs/terraform/certificate.tf @@ -36,3 +36,18 @@ resource "azurerm_key_vault_certificate" "tlscert" { } } + +# pre-create in advance of the real password being created +# so if there is a deleted secret it will be recovered +# +resource "azurerm_key_vault_secret" "cert_password" { + name = local.password_name + value = "0000000000" + key_vault_id = data.azurerm_key_vault.key_vault.id + tags = local.tre_shared_service_tags + + # The password will get replaced with a real one, so we don't want Terraform to try and revert it. + lifecycle { + ignore_changes = all + } +} diff --git a/templates/shared_services/certs/terraform/locals.tf b/templates/shared_services/certs/terraform/locals.tf index eb6e2bc676..19aa23c554 100644 --- a/templates/shared_services/certs/terraform/locals.tf +++ b/templates/shared_services/certs/terraform/locals.tf @@ -26,4 +26,5 @@ locals { cmk_name = "tre-encryption-${var.tre_id}" encryption_identity_name = "id-encryption-${var.tre_id}" + password_name = "${var.cert_name}-password" } diff --git a/templates/shared_services/certs/terraform/outputs.tf b/templates/shared_services/certs/terraform/outputs.tf index 882e91b2da..844163ebb0 100644 --- a/templates/shared_services/certs/terraform/outputs.tf +++ b/templates/shared_services/certs/terraform/outputs.tf @@ -17,3 +17,7 @@ output "resource_group_name" { output "keyvault_name" { value = data.azurerm_key_vault.key_vault.name } + +output "password_name" { + value = local.password_name +} diff --git a/templates/shared_services/firewall/porter.yaml b/templates/shared_services/firewall/porter.yaml index ffba80504b..19e820c4d7 100644 --- a/templates/shared_services/firewall/porter.yaml +++ b/templates/shared_services/firewall/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-firewall -version: 1.3.0 +version: 1.3.2 description: "An Azure TRE Firewall shared service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/shared_services/firewall/terraform/firewall.tf b/templates/shared_services/firewall/terraform/firewall.tf index 6697a359b6..6f4dedc816 100644 --- a/templates/shared_services/firewall/terraform/firewall.tf +++ b/templates/shared_services/firewall/terraform/firewall.tf @@ -1,4 +1,5 @@ resource "azurerm_public_ip" "fwtransit" { + count = var.firewall_force_tunnel_ip != "" ? 0 : 1 name = "pip-fw-${var.tre_id}" resource_group_name = local.core_resource_group_name location = data.azurerm_resource_group.rg.location @@ -10,8 +11,8 @@ resource "azurerm_public_ip" "fwtransit" { } moved { - from = azurerm_public_ip.fwpip - to = azurerm_public_ip.fwtransit + from = azurerm_public_ip.fwtransit + to = azurerm_public_ip.fwtransit[0] } resource "azurerm_public_ip" "fwmanagement" { @@ -38,7 +39,7 @@ resource "azurerm_firewall" "fw" { ip_configuration { name = "fw-ip-configuration" subnet_id = data.azurerm_subnet.firewall.id - public_ip_address_id = azurerm_public_ip.fwtransit.id + public_ip_address_id = var.firewall_force_tunnel_ip != "" ? null : azurerm_public_ip.fwtransit[0].id } dynamic "management_ip_configuration" { diff --git a/templates/shared_services/gitea/porter.yaml b/templates/shared_services/gitea/porter.yaml index e2d3c8b0d5..8b7eaaebc0 100644 --- a/templates/shared_services/gitea/porter.yaml +++ b/templates/shared_services/gitea/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-shared-service-gitea -version: 1.1.4 +version: 1.1.5 description: "A Gitea shared service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/shared_services/gitea/terraform/.terraform.lock.hcl b/templates/shared_services/gitea/terraform/.terraform.lock.hcl index baa9c555b3..410b9232d0 100644 --- a/templates/shared_services/gitea/terraform/.terraform.lock.hcl +++ b/templates/shared_services/gitea/terraform/.terraform.lock.hcl @@ -2,22 +2,22 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.0" - constraints = "3.117.0" + version = "4.14.0" + constraints = "4.14.0" hashes = [ - "h1:Ynfg+Iy7x6K8M6W1AhqXCe3wkoiqIQhROlca7C3KC3w=", - "zh:2e25f47492366821a786762369f0e0921cc9452d64bfd5075f6fdfcf1a9c6d70", - "zh:41eb34f2f7469bf3eb1019dfb0e7fc28256f809824016f4f8b9d691bf473b2ac", - "zh:48bb9c87b3d928da1abc1d3db75453c9725de4674c612daf3800160cc7145d30", - "zh:5d6b0de0bbd78943fcc65c53944ef4496329e247f434c6eab86ed051c5cea67b", - "zh:78c9f6fdb1206a89cf0e6706b4f46178169a93b6c964a4cad8a321058ccbd9b4", - "zh:793b702c352589d4360b580d4a1cf654a7439d2ad6bdb7bfea91de07bc4b0fac", - "zh:7ed687ff0a5509463a592f97431863574fe5cc80a34e395be06766215b8c6285", - "zh:955ba18789bd15592824eb426a8d0f38595bd09fffc6939c1c58933489c1a71e", - "zh:bf5949a55be0714cd9c8815d472eae4baa48ba06d0f6bf2b96775869acda8a54", - "zh:da5d31f635abd2c645ffc76d6176d73f646128e73720cc368247cc424975c127", - "zh:eed5a66d59883c9c56729b0a964a2b60d758ea7489ef3e920a6fbd48518ce5f5", + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", ] } diff --git a/templates/shared_services/gitea/terraform/gitea-webapp.tf b/templates/shared_services/gitea/terraform/gitea-webapp.tf index 285b1b95d6..5cbf0c462f 100644 --- a/templates/shared_services/gitea/terraform/gitea-webapp.tf +++ b/templates/shared_services/gitea/terraform/gitea-webapp.tf @@ -66,7 +66,7 @@ resource "azurerm_linux_web_app" "gitea" { container_registry_managed_identity_client_id = azurerm_user_assigned_identity.gitea_id.client_id ftps_state = "Disabled" always_on = true - minimum_tls_version = "1.2" + minimum_tls_version = "1.3" vnet_route_all_enabled = true application_stack { @@ -129,11 +129,14 @@ resource "azurerm_monitor_diagnostic_setting" "webapp_gitea" { target_resource_id = azurerm_linux_web_app.gitea.id log_analytics_workspace_id = data.azurerm_log_analytics_workspace.tre.id - dynamic "log" { - for_each = data.azurerm_monitor_diagnostic_categories.webapp.log_category_types + dynamic "enabled_log" { + for_each = [ + for category in data.azurerm_monitor_diagnostic_categories.webapp.log_category_types : + category if contains(local.webapp_diagnostic_categories_enabled, category) + ] content { - category = log.value - enabled = contains(local.webapp_diagnostic_categories_enabled, log.value) ? true : false + category = enabled_log.value + } } diff --git a/templates/shared_services/gitea/terraform/main.tf b/templates/shared_services/gitea/terraform/main.tf index 7765d3385b..61aca317c0 100644 --- a/templates/shared_services/gitea/terraform/main.tf +++ b/templates/shared_services/gitea/terraform/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=3.117.0" + version = "=4.14.0" } local = { source = "hashicorp/local" diff --git a/templates/workspace_services/gitea/porter.yaml b/templates/workspace_services/gitea/porter.yaml index d50dff1681..b15bd80a9e 100644 --- a/templates/workspace_services/gitea/porter.yaml +++ b/templates/workspace_services/gitea/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-gitea -version: 1.2.2 +version: 1.2.3 description: "A Gitea workspace service" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/gitea/terraform/.terraform.lock.hcl b/templates/workspace_services/gitea/terraform/.terraform.lock.hcl index a7fe6e4fb5..828f6f3414 100644 --- a/templates/workspace_services/gitea/terraform/.terraform.lock.hcl +++ b/templates/workspace_services/gitea/terraform/.terraform.lock.hcl @@ -2,22 +2,22 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.0" - constraints = "3.117.0" + version = "4.14.0" + constraints = "4.14.0" hashes = [ - "h1:Ynfg+Iy7x6K8M6W1AhqXCe3wkoiqIQhROlca7C3KC3w=", - "zh:2e25f47492366821a786762369f0e0921cc9452d64bfd5075f6fdfcf1a9c6d70", - "zh:41eb34f2f7469bf3eb1019dfb0e7fc28256f809824016f4f8b9d691bf473b2ac", - "zh:48bb9c87b3d928da1abc1d3db75453c9725de4674c612daf3800160cc7145d30", - "zh:5d6b0de0bbd78943fcc65c53944ef4496329e247f434c6eab86ed051c5cea67b", - "zh:78c9f6fdb1206a89cf0e6706b4f46178169a93b6c964a4cad8a321058ccbd9b4", - "zh:793b702c352589d4360b580d4a1cf654a7439d2ad6bdb7bfea91de07bc4b0fac", - "zh:7ed687ff0a5509463a592f97431863574fe5cc80a34e395be06766215b8c6285", - "zh:955ba18789bd15592824eb426a8d0f38595bd09fffc6939c1c58933489c1a71e", - "zh:bf5949a55be0714cd9c8815d472eae4baa48ba06d0f6bf2b96775869acda8a54", - "zh:da5d31f635abd2c645ffc76d6176d73f646128e73720cc368247cc424975c127", - "zh:eed5a66d59883c9c56729b0a964a2b60d758ea7489ef3e920a6fbd48518ce5f5", + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", ] } diff --git a/templates/workspace_services/gitea/terraform/gitea-webapp.tf b/templates/workspace_services/gitea/terraform/gitea-webapp.tf index b9d6643c9d..10602796d3 100644 --- a/templates/workspace_services/gitea/terraform/gitea-webapp.tf +++ b/templates/workspace_services/gitea/terraform/gitea-webapp.tf @@ -76,7 +76,7 @@ resource "azurerm_linux_web_app" "gitea" { container_registry_managed_identity_client_id = azurerm_user_assigned_identity.gitea_id.client_id ftps_state = "Disabled" always_on = true - minimum_tls_version = "1.2" + minimum_tls_version = "1.3" vnet_route_all_enabled = true application_stack { @@ -138,11 +138,13 @@ resource "azurerm_monitor_diagnostic_setting" "gitea" { target_resource_id = azurerm_linux_web_app.gitea.id log_analytics_workspace_id = data.azurerm_log_analytics_workspace.tre.id - dynamic "log" { - for_each = data.azurerm_monitor_diagnostic_categories.gitea.log_category_types + dynamic "enabled_log" { + for_each = [ + for category in data.azurerm_monitor_diagnostic_categories.gitea.log_category_types : + category if contains(local.web_app_diagnostic_categories_enabled, category) + ] content { - category = log.value - enabled = contains(local.web_app_diagnostic_categories_enabled, log.value) ? true : false + category = enabled_log.value } } diff --git a/templates/workspace_services/gitea/terraform/main.tf b/templates/workspace_services/gitea/terraform/main.tf index 5eb181ce8f..8ff9f97222 100644 --- a/templates/workspace_services/gitea/terraform/main.tf +++ b/templates/workspace_services/gitea/terraform/main.tf @@ -3,7 +3,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=3.117.0" + version = "=4.14.0" } random = { source = "hashicorp/random" diff --git a/templates/workspace_services/guacamole/porter.yaml b/templates/workspace_services/guacamole/porter.yaml index 7f6b523b6b..c1d16965d1 100644 --- a/templates/workspace_services/guacamole/porter.yaml +++ b/templates/workspace_services/guacamole/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-service-guacamole -version: 0.12.7 +version: 0.12.8 description: "An Azure TRE service for Guacamole" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspace_services/guacamole/terraform/.terraform.lock.hcl b/templates/workspace_services/guacamole/terraform/.terraform.lock.hcl index acd21261d5..9e59e77e40 100644 --- a/templates/workspace_services/guacamole/terraform/.terraform.lock.hcl +++ b/templates/workspace_services/guacamole/terraform/.terraform.lock.hcl @@ -2,22 +2,22 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.0" - constraints = "3.117.0" + version = "4.14.0" + constraints = "4.14.0" hashes = [ - "h1:Ynfg+Iy7x6K8M6W1AhqXCe3wkoiqIQhROlca7C3KC3w=", - "zh:2e25f47492366821a786762369f0e0921cc9452d64bfd5075f6fdfcf1a9c6d70", - "zh:41eb34f2f7469bf3eb1019dfb0e7fc28256f809824016f4f8b9d691bf473b2ac", - "zh:48bb9c87b3d928da1abc1d3db75453c9725de4674c612daf3800160cc7145d30", - "zh:5d6b0de0bbd78943fcc65c53944ef4496329e247f434c6eab86ed051c5cea67b", - "zh:78c9f6fdb1206a89cf0e6706b4f46178169a93b6c964a4cad8a321058ccbd9b4", - "zh:793b702c352589d4360b580d4a1cf654a7439d2ad6bdb7bfea91de07bc4b0fac", - "zh:7ed687ff0a5509463a592f97431863574fe5cc80a34e395be06766215b8c6285", - "zh:955ba18789bd15592824eb426a8d0f38595bd09fffc6939c1c58933489c1a71e", - "zh:bf5949a55be0714cd9c8815d472eae4baa48ba06d0f6bf2b96775869acda8a54", - "zh:da5d31f635abd2c645ffc76d6176d73f646128e73720cc368247cc424975c127", - "zh:eed5a66d59883c9c56729b0a964a2b60d758ea7489ef3e920a6fbd48518ce5f5", + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", ] } diff --git a/templates/workspace_services/guacamole/terraform/providers.tf b/templates/workspace_services/guacamole/terraform/providers.tf index fc62705f9b..62d4d8d07b 100644 --- a/templates/workspace_services/guacamole/terraform/providers.tf +++ b/templates/workspace_services/guacamole/terraform/providers.tf @@ -2,7 +2,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=3.117.0" + version = "=4.14.0" } local = { source = "hashicorp/local" diff --git a/templates/workspace_services/guacamole/terraform/web_app.tf b/templates/workspace_services/guacamole/terraform/web_app.tf index ac6a2ceb05..d56f6bd3fc 100644 --- a/templates/workspace_services/guacamole/terraform/web_app.tf +++ b/templates/workspace_services/guacamole/terraform/web_app.tf @@ -27,7 +27,7 @@ resource "azurerm_linux_web_app" "guacamole" { container_registry_managed_identity_client_id = azurerm_user_assigned_identity.guacamole_id.client_id ftps_state = "Disabled" vnet_route_all_enabled = true - minimum_tls_version = "1.2" + minimum_tls_version = "1.3" application_stack { docker_registry_url = "https://${data.azurerm_container_registry.mgmt_acr.login_server}" diff --git a/templates/workspace_services/ohdsi/porter.yaml b/templates/workspace_services/ohdsi/porter.yaml index 15f919d400..cc9b60e4eb 100644 --- a/templates/workspace_services/ohdsi/porter.yaml +++ b/templates/workspace_services/ohdsi/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-service-ohdsi -version: 0.3.2 +version: 0.3.3 description: "An OHDSI workspace service" registry: azuretre dockerfile: Dockerfile.tmpl diff --git a/templates/workspace_services/ohdsi/terraform/.terraform.lock.hcl b/templates/workspace_services/ohdsi/terraform/.terraform.lock.hcl index 82ad71e493..c94c1c299f 100644 --- a/templates/workspace_services/ohdsi/terraform/.terraform.lock.hcl +++ b/templates/workspace_services/ohdsi/terraform/.terraform.lock.hcl @@ -2,22 +2,22 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/azurerm" { - version = "3.117.0" - constraints = "3.117.0" + version = "4.14.0" + constraints = "4.14.0" hashes = [ - "h1:Ynfg+Iy7x6K8M6W1AhqXCe3wkoiqIQhROlca7C3KC3w=", - "zh:2e25f47492366821a786762369f0e0921cc9452d64bfd5075f6fdfcf1a9c6d70", - "zh:41eb34f2f7469bf3eb1019dfb0e7fc28256f809824016f4f8b9d691bf473b2ac", - "zh:48bb9c87b3d928da1abc1d3db75453c9725de4674c612daf3800160cc7145d30", - "zh:5d6b0de0bbd78943fcc65c53944ef4496329e247f434c6eab86ed051c5cea67b", - "zh:78c9f6fdb1206a89cf0e6706b4f46178169a93b6c964a4cad8a321058ccbd9b4", - "zh:793b702c352589d4360b580d4a1cf654a7439d2ad6bdb7bfea91de07bc4b0fac", - "zh:7ed687ff0a5509463a592f97431863574fe5cc80a34e395be06766215b8c6285", - "zh:955ba18789bd15592824eb426a8d0f38595bd09fffc6939c1c58933489c1a71e", - "zh:bf5949a55be0714cd9c8815d472eae4baa48ba06d0f6bf2b96775869acda8a54", - "zh:da5d31f635abd2c645ffc76d6176d73f646128e73720cc368247cc424975c127", - "zh:eed5a66d59883c9c56729b0a964a2b60d758ea7489ef3e920a6fbd48518ce5f5", + "h1:FYZ9qh8i3X2gDmUTe1jJ/VzdSyjGjVmhBzv2R8D6CBo=", + "zh:05aaea16fc5f27b14d9fbad81654edf0638949ed3585576b2219c76a2bee095a", + "zh:065ce6ed16ba3fa7efcf77888ea582aead54e6a28f184c6701b73d71edd64bb0", + "zh:3c0cd17c249d18aa2e0120acb5f0c14810725158b379a67fec1331110e7c50df", + "zh:5a3ba3ffb2f1ce519fe3bf84a7296aa5862c437c70c62f0b0a5293bea9f2d01c", + "zh:7a8e9d72fa2714f4d567270b1761d4b4e788de7c15dada7db0cf0e29933185a2", + "zh:a11e190073f31c1238c15af29b9162e0f4564f6b0cd0310a3fa94102738450dc", + "zh:a5c004114410cc6dcb8fed584c9f3b84283b58025b0073a7e88d2bdb27840dfa", + "zh:a674a41db118e244eda7591e455d2ec338626664e0856e4125e909eb038f78db", + "zh:b5139010e4cbb2cb1a27c775610593c1c8063d3a7c82b00a65006509c434df2f", + "zh:cbb031223ccd8b099ac4d19b92641142f330b90f2fc6452843e445bae28f832c", "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:f7e7db1b94082a4ac3d4af3dabe7bbd335e1679305bf8e29d011f0ee440724ca", ] } diff --git a/templates/workspace_services/ohdsi/terraform/atlas_ui.tf b/templates/workspace_services/ohdsi/terraform/atlas_ui.tf index dd688bcf66..24879a80da 100644 --- a/templates/workspace_services/ohdsi/terraform/atlas_ui.tf +++ b/templates/workspace_services/ohdsi/terraform/atlas_ui.tf @@ -30,12 +30,12 @@ resource "azurerm_linux_web_app" "atlas_ui" { client_affinity_enabled = false site_config { - always_on = false - ftps_state = "Disabled" + always_on = false + ftps_state = "Disabled" + minimum_tls_version = "1.3" application_stack { - docker_image = "index.docker.io/${local.atlas_ui_docker_image_name}" - docker_image_tag = local.atlas_ui_docker_image_tag + docker_image_name = "index.docker.io/${local.atlas_ui_docker_image_name}:${local.atlas_ui_docker_image_tag}" } } diff --git a/templates/workspace_services/ohdsi/terraform/ohdsi_web_api.tf b/templates/workspace_services/ohdsi/terraform/ohdsi_web_api.tf index a3640d4676..2606dbc5cb 100644 --- a/templates/workspace_services/ohdsi/terraform/ohdsi_web_api.tf +++ b/templates/workspace_services/ohdsi/terraform/ohdsi_web_api.tf @@ -33,12 +33,12 @@ resource "azurerm_linux_web_app" "ohdsi_webapi" { client_affinity_enabled = false site_config { - always_on = true - ftps_state = "Disabled" + always_on = true + ftps_state = "Disabled" + minimum_tls_version = "1.3" application_stack { - docker_image = "index.docker.io/${local.ohdsi_api_docker_image_name}" - docker_image_tag = local.ohdsi_api_docker_image_tag + docker_image_name = "index.docker.io/${local.ohdsi_api_docker_image_name}:${local.ohdsi_api_docker_image_tag}" } } diff --git a/templates/workspace_services/ohdsi/terraform/providers.tf b/templates/workspace_services/ohdsi/terraform/providers.tf index 58928db7c2..ce7c57ddcc 100644 --- a/templates/workspace_services/ohdsi/terraform/providers.tf +++ b/templates/workspace_services/ohdsi/terraform/providers.tf @@ -2,7 +2,7 @@ terraform { required_providers { azurerm = { source = "hashicorp/azurerm" - version = "=3.117.0" + version = "=4.14.0" } local = { source = "hashicorp/local" diff --git a/templates/workspaces/airlock-import-review/porter.yaml b/templates/workspaces/airlock-import-review/porter.yaml index 56f90dbc70..a651294ea9 100644 --- a/templates/workspaces/airlock-import-review/porter.yaml +++ b/templates/workspaces/airlock-import-review/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-airlock-import-review -version: 0.14.2 +version: 0.14.3 description: "A workspace to do Airlock Data Import Reviews for Azure TRE" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspaces/airlock-import-review/template_schema.json b/templates/workspaces/airlock-import-review/template_schema.json index 180e360abc..09d7d6be87 100644 --- a/templates/workspaces/airlock-import-review/template_schema.json +++ b/templates/workspaces/airlock-import-review/template_schema.json @@ -15,6 +15,7 @@ "title": "App Service Plan SKU", "description": "The SKU that will be used when deploying an Azure App Service Plan.", "default": "P1v3", + "updateable": true, "enum": [ "P0v3", "P1v3", diff --git a/templates/workspaces/base/porter.yaml b/templates/workspaces/base/porter.yaml index a7e09fa692..ed48af1a9b 100644 --- a/templates/workspaces/base/porter.yaml +++ b/templates/workspaces/base/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-base -version: 1.9.2 +version: 1.9.3 description: "A base Azure TRE workspace" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspaces/base/template_schema.json b/templates/workspaces/base/template_schema.json index b456b5f044..24cec47f34 100644 --- a/templates/workspaces/base/template_schema.json +++ b/templates/workspaces/base/template_schema.json @@ -27,6 +27,7 @@ "title": "App Service Plan SKU", "description": "The SKU that will be used when deploying an Azure App Service Plan.", "default": "P1v3", + "updateable": true, "enum": [ "P0v3", "P1v3", diff --git a/templates/workspaces/unrestricted/porter.yaml b/templates/workspaces/unrestricted/porter.yaml index b8bd2becae..6c9cb1e43b 100644 --- a/templates/workspaces/unrestricted/porter.yaml +++ b/templates/workspaces/unrestricted/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-unrestricted -version: 0.13.2 +version: 0.13.3 description: "A base Azure TRE workspace" dockerfile: Dockerfile.tmpl registry: azuretre diff --git a/templates/workspaces/unrestricted/template_schema.json b/templates/workspaces/unrestricted/template_schema.json index f9c8a807f1..6ebbb1c159 100644 --- a/templates/workspaces/unrestricted/template_schema.json +++ b/templates/workspaces/unrestricted/template_schema.json @@ -27,6 +27,7 @@ "title": "App Service Plan SKU", "description": "The SKU that will be used when deploying an Azure App Service Plan.", "default": "P1v3", + "updateable": true, "enum": [ "P0v3", "P1v3", diff --git a/ui/README.md b/ui/README.md deleted file mode 100644 index b605e4bc71..0000000000 --- a/ui/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TRE UI - -Please see the docs for a full overview and deployment instructions. - -The UI was built using Create React App and Microsoft Fluent UI. Further details on this in the ./app/README. - -## Run the UI -- Ensure `deploy_ui=false` is not set in your `./config.yaml` file -- In the root of the repo, run `make tre-deploy`. This will provision the necessary resources in Azure, build and deploy the UI to Azure blob storage, behind the App Gateway used for the API. The deployment process will also create the necessary `config.json`, using the `config.source.json` as a template. -- In Microsoft Entra ID, locate the TRE Client Apps app (possibly called Swagger App). In the Authentication section add reply URIs for: - - `http://localhost:3000` (if wanting to run locally) - - Your deployed App Url - `https://{TRE_ID}.{LOCATION}.cloudapp.azure.com`. - -At this point you should be able to navigate to the web app in Azure, log in, and see your workspaces. - -### To run locally -- `cd ./ui/app` -- `yarn start` - -After making changes to the code, redeploy to Azure by running `make build-and-deploy-ui` in the root of the dev container. diff --git a/ui/app/.prettierrc b/ui/app/.prettierrc new file mode 100644 index 0000000000..dc6958febb --- /dev/null +++ b/ui/app/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": false, + "semi": true +} diff --git a/ui/app/README.md b/ui/app/README.md deleted file mode 100644 index 387b475e1e..0000000000 --- a/ui/app/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# Getting Started with Create React App and Fluent UI - -This is a [Create React App](https://github.com/facebook/create-react-app) based repo that comes with Fluent UI pre-installed! - -## Available Scripts - -In the project directory, you can run: - -### `yarn start` - -Runs the app in the development mode.
-Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -The page will reload if you make edits.
-You will also see any lint errors in the console. - -### `yarn test` - -Launches the test runner in the interactive watch mode.
-See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. - -### `yarn build` - -Builds the app for production to the `build` folder.
-It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.
-Your app is ready to be deployed! - -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. - -### `yarn eject` - -**Note: this is a one-way operation. Once you `eject`, you can’t go back!** - -If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. - -Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. - -You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. - -## Learn More - -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). - -To learn React, check out the [React documentation](https://reactjs.org/). - -## Contributing - -This project welcomes contributions and suggestions. Most contributions require you to agree to a -Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us -the rights to use your contribution. For details, visit [CLA](https://cla.microsoft.com). - -When you submit a pull request, a CLA-bot will automatically determine whether you need to provide -a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions -provided by the bot. You will only need to do this once across all repos using our CLA. - -This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). -For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/ui/app/eslint.config.js b/ui/app/eslint.config.js new file mode 100644 index 0000000000..c61fac8c72 --- /dev/null +++ b/ui/app/eslint.config.js @@ -0,0 +1,24 @@ +import eslintConfigPrettier from "eslint-config-prettier"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import typescriptParser from "@typescript-eslint/parser"; + +export default [ + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + }, + plugins: { + "@typescript-eslint": typescriptEslint, + }, + rules: {}, + }, + eslintConfigPrettier, +]; diff --git a/ui/app/index.html b/ui/app/index.html new file mode 100644 index 0000000000..119f64be91 --- /dev/null +++ b/ui/app/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + Azure TRE + + + +
+ + + diff --git a/ui/app/package.json b/ui/app/package.json index b1a9a66992..9d8321c24a 100644 --- a/ui/app/package.json +++ b/ui/app/package.json @@ -1,52 +1,67 @@ { "name": "tre-ui", - "version": "0.6.3", + "version": "0.7.0", "private": true, + "type": "module", "dependencies": { "@azure/msal-browser": "^2.35.0", "@azure/msal-react": "^1.5.12", - "@fluentui/react": "^8.120.3", + "@fluentui/react": "^8.122.1", "@fluentui/react-file-type-icons": "^8.12.6", "@reduxjs/toolkit": "^1.8.6", - "@rjsf/core": "^4.2.3", - "@rjsf/fluent-ui": "^4.2.3", - "@testing-library/dom": "^7.21.4", - "@testing-library/jest-dom": "^6.2.0", - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", - "@types/jest": "^29.5.0", - "@types/node": "^20.16.12", + "@rjsf/core": "^5.24.3", + "@rjsf/fluent-ui": "^5.24.3", + "@rjsf/utils": "^5.24.3", + "@rjsf/validator-ajv8": "^5.24.3", + "@types/node": "^20.17.14", "@types/react": "^18.3.16", "@types/react-dom": "^18.2.6", + "@vitejs/plugin-react-swc": "latest", "moment": "^2.29.4", - "node-sass": "^8.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^8.0.3", "react-redux": "^8.0.4", - "react-router-dom": "6.28.0", + "react-router-dom": "6.28.2", "remark-gfm": "^3.0.1", "typescript": "^5.6.3", + "vite": "latest", + "vite-plugin-eslint": "^1.8.1", + "vite-plugin-svgr": "latest", + "vite-tsconfig-paths": "latest", "web-vitals": "^3.3.0" }, "devDependencies": { - "@babel/core": "^7.23.7", - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/plugin-syntax-flow": "^7.23.3", - "@babel/plugin-transform-react-jsx": "^7.23.4", - "react-scripts": "5.0.1" + "@testing-library/dom": "^7.21.4", + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.17.14", + "@types/react": "^18.3.16", + "@types/react-dom": "^18.2.6", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "@vitest/coverage-v8": "latest", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.3", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.14.0", + "jsdom": "latest", + "prettier": "3.5.0", + "sass-embedded": "^1.83.4", + "vitest": "latest" }, "scripts": { - "start": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", - "eject": "react-scripts eject" - }, - "eslintConfig": { - "extends": [ - "react-app", - "react-app/jest" - ] + "start": "vite", + "build": "tsc && vite build", + "serve": "vite preview", + "test": "vitest", + "test:coverage": "vitest run --coverage --watch=false", + "lint": "eslint .", + "format": "prettier --write ." }, "browserslist": { "production": [ diff --git a/ui/app/public/index.html b/ui/app/public/index.html index 3412482b4e..493346824c 100644 --- a/ui/app/public/index.html +++ b/ui/app/public/index.html @@ -1,15 +1,18 @@ - + - + - + - + Azure TRE diff --git a/ui/app/src/App.scss b/ui/app/src/App.scss index 704e6dfeb0..0f7c87cd8b 100644 --- a/ui/app/src/App.scss +++ b/ui/app/src/App.scss @@ -1,7 +1,8 @@ body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', - 'Droid Sans', 'Helvetica Neue', sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -16,7 +17,8 @@ h2 { } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } .tre-logout-message { @@ -88,7 +90,7 @@ ul.tre-notifications-steps-list li { .tre-home-link { color: #fff; text-decoration: none; - font-size:1.2rem; + font-size: 1.2rem; } .tre-user-menu { @@ -118,7 +120,7 @@ ul.tre-notifications-steps-list li { } } -.tre-hide-chevron i[data-icon-name=ChevronDown] { +.tre-hide-chevron i[data-icon-name="ChevronDown"] { display: none; } @@ -174,50 +176,50 @@ ul.tre-notifications-steps-list li { margin-bottom: 10px; } -input[readonly]{ - background-color:#efefef; +input[readonly] { + background-color: #efefef; } -.tre-badge{ - border-radius:4px; +.tre-badge { + border-radius: 4px; background-color: #efefef; - padding:2px 6px; + padding: 2px 6px; text-transform: capitalize; - display:inline-block; - font-size:12px; + display: inline-block; + font-size: 12px; } -.tre-badge-in-progress{ +.tre-badge-in-progress { background-color: #ce7b00; color: #fff; } -.tre-badge-failed{ +.tre-badge-failed { background-color: #990000; color: #fff; padding-top: 4px; padding-left: 7px; font-size: 16px; } -.tre-badge-success{ +.tre-badge-success { background-color: #006600; color: #fff; } -.tre-complex-list{ +.tre-complex-list { list-style: none; - padding:0 0 0 20px; - margin:0; + padding: 0 0 0 20px; + margin: 0; } -.tre-complex-list-border{ +.tre-complex-list-border { border-bottom: 1px #ccc solid; - margin-left:-15px; + margin-left: -15px; } -.tre-complex-list-string{ - padding-left:20px; +.tre-complex-list-string { + padding-left: 20px; } -.tre-complex-list .ms-Icon{ - font-size:12px!important; +.tre-complex-list .ms-Icon { + font-size: 12px !important; font-weight: bold; position: relative; - top:2px; + top: 2px; } // Classes for rendering power state badges @@ -226,7 +228,8 @@ input[readonly]{ color: #636262; margin: 6px; - .tre-power-on, .tre-power-off { + .tre-power-on, + .tre-power-off { height: 8px; width: 8px; background-color: #006600; @@ -267,28 +270,81 @@ input[readonly]{ } /* border around sub-blocks */ -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-object, -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-array { +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-object, +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-array { border: 1px #ccc dashed; padding: 10px; background-color: #fcfcfc; } /* sub titles and sub-sub titles */ -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-object > label.ms-Label, -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-array > label.ms-Label { +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-object + > label.ms-Label, +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-array + > label.ms-Label { font-size: 20px; } -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-object > .ms-Grid > .ms-Grid-row > .ms-Grid-col > label.ms-Label, -.ms-Panel-content .rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-array > .ms-Grid > .ms-Grid-row > .ms-Grid-col > label.ms-Label { +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-object + > .ms-Grid + > .ms-Grid-row + > .ms-Grid-col + > label.ms-Label, +.ms-Panel-content + .rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-array + > .ms-Grid + > .ms-Grid-row + > .ms-Grid-col + > label.ms-Label { font-size: 16px; } /* remove secondary template description at the bottom of each template + sub blocks */ .rjsf > .ms-Grid-col > span:last-of-type, -.rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-object > span:last-of-type, -.rjsf > .ms-Grid-col > .ms-Grid > .ms-Grid-row > .field-object > .ms-Grid > .ms-Grid-row > .ms-Grid-col > span:last-of-type { +.rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-object + > span:last-of-type, +.rjsf + > .ms-Grid-col + > .ms-Grid + > .ms-Grid-row + > .field-object + > .ms-Grid + > .ms-Grid-row + > .ms-Grid-col + > span:last-of-type { display: none; } diff --git a/ui/app/src/App.test.tsx b/ui/app/src/App.test.tsx index ac873a8bce..d55194710b 100644 --- a/ui/app/src/App.test.tsx +++ b/ui/app/src/App.test.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { App } from './App'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { App } from "./App"; it('renders "Welcome to Your Fluent UI App"', () => { render(); diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 8f21c2d260..24d0b5220d 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -1,45 +1,65 @@ -import React, { useEffect, useState } from 'react'; -import { DefaultPalette, IStackStyles, MessageBar, MessageBarType, Stack } from '@fluentui/react'; -import './App.scss'; -import { TopNav } from './components/shared/TopNav'; -import { Routes, Route } from 'react-router-dom'; -import { RootLayout } from './components/root/RootLayout'; -import { WorkspaceProvider } from './components/workspaces/WorkspaceProvider'; -import { MsalAuthenticationTemplate } from '@azure/msal-react'; -import { InteractionType } from '@azure/msal-browser'; -import { Workspace } from './models/workspace'; -import { AppRolesContext } from './contexts/AppRolesContext'; -import { WorkspaceContext } from './contexts/WorkspaceContext'; -import { GenericErrorBoundary } from './components/shared/GenericErrorBoundary'; -import { HttpMethod, ResultType, useAuthApiCall } from './hooks/useAuthApiCall'; -import { ApiEndpoint } from './models/apiEndpoints'; -import { CreateUpdateResource } from './components/shared/create-update-resource/CreateUpdateResource'; -import { CreateUpdateResourceContext } from './contexts/CreateUpdateResourceContext'; -import { CreateFormResource, ResourceType } from './models/resourceType'; -import { Footer } from './components/shared/Footer'; -import { initializeFileTypeIcons } from '@fluentui/react-file-type-icons'; -import { CostResource } from './models/costs'; -import { CostsContext } from './contexts/CostsContext'; -import { LoadingState } from './models/loadingState'; +import React, { useEffect, useState } from "react"; +import { + DefaultPalette, + IStackStyles, + MessageBar, + MessageBarType, + Stack, +} from "@fluentui/react"; +import "./App.scss"; +import { TopNav } from "./components/shared/TopNav"; +import { Routes, Route } from "react-router-dom"; +import { RootLayout } from "./components/root/RootLayout"; +import { WorkspaceProvider } from "./components/workspaces/WorkspaceProvider"; +import { MsalAuthenticationTemplate } from "@azure/msal-react"; +import { InteractionType } from "@azure/msal-browser"; +import { Workspace } from "./models/workspace"; +import { AppRolesContext } from "./contexts/AppRolesContext"; +import { WorkspaceContext } from "./contexts/WorkspaceContext"; +import { GenericErrorBoundary } from "./components/shared/GenericErrorBoundary"; +import { HttpMethod, ResultType, useAuthApiCall } from "./hooks/useAuthApiCall"; +import { ApiEndpoint } from "./models/apiEndpoints"; +import { CreateUpdateResource } from "./components/shared/create-update-resource/CreateUpdateResource"; +import { CreateUpdateResourceContext } from "./contexts/CreateUpdateResourceContext"; +import { CreateFormResource, ResourceType } from "./models/resourceType"; +import { Footer } from "./components/shared/Footer"; +import { initializeFileTypeIcons } from "@fluentui/react-file-type-icons"; +import { CostResource } from "./models/costs"; +import { CostsContext } from "./contexts/CostsContext"; +import { LoadingState } from "./models/loadingState"; export const App: React.FunctionComponent = () => { const [appRoles, setAppRoles] = useState([] as Array); const [selectedWorkspace, setSelectedWorkspace] = useState({} as Workspace); const [workspaceRoles, setWorkspaceRoles] = useState([] as Array); - const [workspaceCosts, setWorkspaceCosts] = useState([] as Array); + const [workspaceCosts, setWorkspaceCosts] = useState( + [] as Array, + ); const [costs, setCosts] = useState([] as Array); - const [costsLoadingState, setCostsLoadingState] = useState(LoadingState.Loading); + const [costsLoadingState, setCostsLoadingState] = useState( + LoadingState.Loading, + ); const [createFormOpen, setCreateFormOpen] = useState(false); - const [createFormResource, setCreateFormResource] = useState({ resourceType: ResourceType.Workspace } as CreateFormResource); + const [createFormResource, setCreateFormResource] = useState({ + resourceType: ResourceType.Workspace, + } as CreateFormResource); const apiCall = useAuthApiCall(); // set the app roles useEffect(() => { const setAppRolesOnLoad = async () => { - await apiCall(ApiEndpoint.Workspaces, HttpMethod.Get, undefined, undefined, ResultType.JSON, (roles: Array) => { - setAppRoles(roles); - }, true); + await apiCall( + ApiEndpoint.Workspaces, + HttpMethod.Get, + undefined, + undefined, + ResultType.JSON, + (roles: Array) => { + setAppRoles(roles); + }, + true, + ); }; setAppRolesOnLoad(); }, [apiCall]); @@ -49,77 +69,113 @@ export const App: React.FunctionComponent = () => { return ( <> - - ) => { setAppRoles(roles) } - }}> - { - setCreateFormResource(createFormResource); - setCreateFormOpen(true); - } - }} > - - setCreateFormOpen(false)} - resourceType={createFormResource.resourceType} - parentResource={createFormResource.resourceParent} - onAddResource={createFormResource.onAdd} - workspaceApplicationIdURI={createFormResource.workspaceApplicationIdURI} - updateResource={createFormResource.updateResource} - /> - - - - - - - ) => {setCosts(costs)}, - setLoadingState: (loadingState: LoadingState) => {setCostsLoadingState(loadingState)} - }}> - - } /> - ) => {setWorkspaceRoles(roles)}, - costs: workspaceCosts, - setCosts: (costs: Array) => {setWorkspaceCosts(costs)}, - workspace: selectedWorkspace, - setWorkspace: (w: Workspace) => {setSelectedWorkspace(w)}, - workspaceApplicationIdURI: selectedWorkspace.properties?.scope_id - }}> - - - } /> - - - - - -