diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a57a4c1c..621a8d27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ **BREAKING CHANGES** ENHANCEMENTS: +* Allow numeric CIDR masks in `address_space_size` (e.g. "23") when requesting auto-assigned address spaces; accepts numeric strings and validates the mask range. ([#4733](https://github.com/microsoft/AzureTRE/issues/4733)) ## (0.29.0) (August 14, 2026) **BREAKING CHANGES** diff --git a/api_app/_version.py b/api_app/_version.py index ae62eb632..cde6d8971 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.26.5" +__version__ = "0.27.0" diff --git a/api_app/api/routes/workspaces.py b/api_app/api/routes/workspaces.py index 04ea2d651..bfc7a147f 100644 --- a/api_app/api/routes/workspaces.py +++ b/api_app/api/routes/workspaces.py @@ -262,7 +262,10 @@ async def create_workspace_service(response: Response, workspace_service_input: # check workspace has address_spaces property if not workspace.properties.get("address_spaces"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.WORKSPACE_DOES_NOT_HAVE_ADDRESS_SPACES_PROPERTY) - workspace_service.properties["address_space"] = await workspace_repo.get_address_space_based_on_size(workspace_service_input.properties) + try: + workspace_service.properties["address_space"] = await workspace_repo.get_address_space_based_on_size(workspace_service_input.properties) + except InvalidInput as e: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) workspace_patch = ResourcePatch() workspace_patch.properties = {"address_spaces": workspace.properties["address_spaces"] + [workspace_service.properties["address_space"]]} # IP address allocation is managed by the API. Ideally this request would happen as a result of the workspace diff --git a/api_app/db/repositories/workspaces.py b/api_app/db/repositories/workspaces.py index 14656c0a6..d36788a9b 100644 --- a/api_app/db/repositories/workspaces.py +++ b/api_app/db/repositories/workspaces.py @@ -160,18 +160,32 @@ def automatically_create_application_registration(self, workspace_properties: di async def get_address_space_based_on_size(self, workspace_properties: dict): # Default the address space to 'small' if not supplied. - address_space_size = workspace_properties.get("address_space_size", "small").lower() + raw_size = workspace_properties.get("address_space_size") + if raw_size is None or str(raw_size).strip() == "": + address_space_size = "small" + else: + address_space_size = str(raw_size).strip().lower() # 773 allow custom sized networks to be requested - if (address_space_size == "custom"): + if address_space_size == "custom": if (await self.validate_address_space(workspace_properties.get("address_space"))): return workspace_properties.get("address_space") else: raise InvalidInput("The custom 'address_space' you requested does not fit in the current network.") - # Default mask is 24 (small) - cidr_netmask = WorkspaceRepository.predefined_address_spaces.get(address_space_size, 24) - return await self.get_new_address_space(cidr_netmask) + # If a numeric cidr was provided (e.g. as a string like "25"), accept it + if address_space_size.isdecimal(): + cidr_netmask = int(address_space_size) + # basic validation for reasonable CIDR mask values + if cidr_netmask < 16 or cidr_netmask > 29: + raise InvalidInput("'address_space_size' numeric value must be between 16 and 29") + return await self.get_new_address_space(cidr_netmask) + + if address_space_size in WorkspaceRepository.predefined_address_spaces: + cidr_netmask = WorkspaceRepository.predefined_address_spaces[address_space_size] + return await self.get_new_address_space(cidr_netmask) + + raise InvalidInput(f"Invalid 'address_space_size': {address_space_size}") # 772 check that the provided address_space is available in the network. async def validate_address_space(self, address_space): diff --git a/api_app/models/schemas/workspace_template.py b/api_app/models/schemas/workspace_template.py index c6d84a458..99540c6ae 100644 --- a/api_app/models/schemas/workspace_template.py +++ b/api_app/models/schemas/workspace_template.py @@ -23,7 +23,7 @@ def get_sample_workspace_template_object(template_name: str = "tre-workspace-bas "address_space_size": Property( type="string", default="small", - description="This can have a value of small, medium, large or custom. If you specify custom, then you need to specify a VNet address space in 'address_space' (e.g. 10.2.1.0/24)") + description="Network address size as a CIDR value or (small /24, medium /22, large /16 or custom with an IP range e.g. 10.2.1.0/25) to be used by the workspace.") }, customActions=[ CustomAction() @@ -72,7 +72,7 @@ class WorkspaceTemplateInCreate(ResourceTemplateInCreate): "address_space_size": { "type": "string", "title": "Address space size", - "description": "Network address size (small, medium, large or custom) to be used by the workspace" + "description": "Network address size as a CIDR value or (small /24, medium /22, large /16 or custom with an IP range e.g. 10.2.1.0/25) to be used by the workspace." }, "address_space": { "type": "string", diff --git a/api_app/tests_ma/test_api/test_routes/test_workspaces.py b/api_app/tests_ma/test_api/test_routes/test_workspaces.py index 986bb8d45..33223d3b0 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspaces.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspaces.py @@ -12,7 +12,7 @@ from models.domain.resource_template import ResourceTemplate from models.schemas.operation import OperationInResponse -from db.errors import EntityDoesNotExist, StorageAccountNameGenerationTimeout, StorageAccountNameCheckFailed +from db.errors import EntityDoesNotExist, InvalidInput, StorageAccountNameGenerationTimeout, StorageAccountNameCheckFailed from db.repositories.workspaces import WorkspaceRepository from db.repositories.workspace_services import WorkspaceServiceRepository from models.domain.authentication import RoleAssignment @@ -803,6 +803,24 @@ async def test_post_workspace_services_creates_workspace_service_with_address_sp assert response.status_code == status.HTTP_202_ACCEPTED assert response.json()["operation"]["resourceId"] == SERVICE_ID + # [POST] /workspaces/{workspace_id}/workspace-services + @patch("api.dependencies.workspaces.WorkspaceRepository.get_address_space_based_on_size", side_effect=InvalidInput("'address_space_size' numeric value must be between 16 and 29")) + @patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id") + @patch("api.routes.workspaces.OperationRepository.resource_has_deployed_operation", return_value=True) + @patch("api.routes.workspaces.WorkspaceServiceRepository.create_workspace_service_item") + async def test_post_workspace_services_returns_422_for_invalid_address_space_size(self, create_workspace_service_item_mock, _, get_workspace_mock, __, app, client, workspace_service_input, basic_workspace_service_template): + workspace = sample_workspace() + workspace.properties["address_spaces"] = ["192.168.0.1/24"] + get_workspace_mock.return_value = workspace + basic_workspace_service_template.properties["address_space"] = "10.1.0.0/24" + create_workspace_service_item_mock.return_value = [sample_workspace_service(), basic_workspace_service_template] + workspace_service_input["properties"]["address_space_size"] = "15" + + response = await client.post(app.url_path_for(strings.API_CREATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID), json=workspace_service_input) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert response.text == "'address_space_size' numeric value must be between 16 and 29" + # [POST] /workspaces/{workspace_id}/workspace-services @patch("api.dependencies.workspaces.WorkspaceRepository.get_new_address_space", return_value="10.1.4.0/24") @patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version") diff --git a/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py b/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py index f7d637599..114ee64f3 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_workpaces_repository.py @@ -414,3 +414,69 @@ async def test_is_workspace_storage_account_available_when_check_times_out(mock_ await workspace_repo.is_workspace_storage_account_available(MagicMock(), workspace_id) assert mock_storage_client_instance.storage_accounts.check_name_availability.call_count == 1 + + +@pytest.mark.asyncio +@patch('core.config.RESOURCE_LOCATION', "useast2") +@patch('core.config.TRE_ID', "9876") +@patch('core.config.CORE_ADDRESS_SPACE', "10.1.0.0/22") +@patch('core.config.TRE_ADDRESS_SPACE', "10.0.0.0/12") +async def test_get_address_space_based_on_size_with_string_19(workspace_repo, basic_workspace_request): + workspace_to_create = basic_workspace_request + # request a /19 + workspace_to_create.properties["address_space_size"] = "19" + address_space = await workspace_repo.get_address_space_based_on_size(workspace_to_create.properties) + assert address_space.endswith('/19') + + +@pytest.mark.asyncio +@patch('core.config.RESOURCE_LOCATION', "useast2") +@patch('core.config.TRE_ID', "9876") +@patch('core.config.CORE_ADDRESS_SPACE', "10.1.0.0/22") +@patch('core.config.TRE_ADDRESS_SPACE', "10.0.0.0/12") +async def test_get_address_space_based_on_size_with_string_29(workspace_repo, basic_workspace_request): + workspace_to_create = basic_workspace_request + # request a /29 + workspace_to_create.properties["address_space_size"] = "29" + address_space = await workspace_repo.get_address_space_based_on_size(workspace_to_create.properties) + assert address_space.endswith('/29') + + +@pytest.mark.asyncio +@patch('core.config.RESOURCE_LOCATION', "useast2") +@patch('core.config.TRE_ID', "9876") +@patch('core.config.CORE_ADDRESS_SPACE', "10.1.0.0/22") +@patch('core.config.TRE_ADDRESS_SPACE', "10.0.0.0/12") +@pytest.mark.parametrize("invalid_size", ["15", "30"]) +async def test_get_address_space_based_on_size_with_invalid_string_raises_error(workspace_repo, basic_workspace_request, invalid_size): + workspace_to_create = basic_workspace_request + workspace_to_create.properties["address_space_size"] = invalid_size + with pytest.raises(InvalidInput) as ex: + await workspace_repo.get_address_space_based_on_size(workspace_to_create.properties) + assert str(ex.value) == "'address_space_size' numeric value must be between 16 and 29" + + +@pytest.mark.asyncio +@patch('core.config.RESOURCE_LOCATION', "useast2") +@patch('core.config.TRE_ID', "9876") +@patch('core.config.CORE_ADDRESS_SPACE', "10.1.0.0/22") +@patch('core.config.TRE_ADDRESS_SPACE', "10.0.0.0/12") +@pytest.mark.parametrize("empty_size", [None, "", " "]) +async def test_get_address_space_based_on_size_with_none_or_empty_address_space_size(workspace_repo, basic_workspace_request, empty_size): + workspace_to_create = basic_workspace_request + workspace_to_create.properties["address_space_size"] = empty_size + assert "10.1.4.0/24" == await workspace_repo.get_address_space_based_on_size(workspace_to_create.properties) + + +@pytest.mark.asyncio +@patch('core.config.RESOURCE_LOCATION', "useast2") +@patch('core.config.TRE_ID', "9876") +@patch('core.config.CORE_ADDRESS_SPACE', "10.1.0.0/22") +@patch('core.config.TRE_ADDRESS_SPACE', "10.0.0.0/12") +@pytest.mark.parametrize("invalid_preset", ["huge", "extra_large", "invalid"]) +async def test_get_address_space_based_on_size_with_unrecognized_preset_raises_error(workspace_repo, basic_workspace_request, invalid_preset): + workspace_to_create = basic_workspace_request + workspace_to_create.properties["address_space_size"] = invalid_preset + with pytest.raises(InvalidInput) as ex: + await workspace_repo.get_address_space_based_on_size(workspace_to_create.properties) + assert str(ex.value) == f"Invalid 'address_space_size': {invalid_preset}" diff --git a/docs/tre-workspace-authors/authoring-workspace-templates.md b/docs/tre-workspace-authors/authoring-workspace-templates.md index eba6bc875..9715c1702 100644 --- a/docs/tre-workspace-authors/authoring-workspace-templates.md +++ b/docs/tre-workspace-authors/authoring-workspace-templates.md @@ -103,13 +103,18 @@ The mandatory parameters for workspace services are: | `tre_id` | string | Unique ID of for the TRE instance. | `tre-dev-42` | | `workspace_id` | string | Unique 4-character long, alphanumeric workspace ID. | `0a9e` | -### Workpace services requiring additional address spaces +### Workspace services requiring additional address spaces Some workspace services may require additional address spaces to be provisioned. This may be necessary if they need advanced network security groups, route tables or delegated subnets. To request an additional address space, the workspace service bundle must define an `address_space` parameter in the `porter.yaml` file. The value of this parameter will be provided by API to the resource processor. The size of the `address_space` will default to `/24`, however other sizes can be requested by including an `address_space_size` as part of the workspace service template. +This parameter accepts the presets `small` (/24), `medium` (/22), `large` (/16), the literal value `custom` together with an explicit `address_space` CIDR (e.g. `10.2.1.0/25`), or a numeric CIDR mask as a string. +The API has support for allocating CIDR subnet masks from "16" to "29". Workspace templates are configured to support from "16" (65,536 IP addresses) to "24" (256 IP addresses). +Depending on the workspace service you are deploying you may configure a template with a CIDR up to "29" which has only 3 usable IP addresses as Azure reserves the first four and last address of every subnet. +The workspace service IP addresses are in addition to the workspace addresses and the address size is unrelated to the workspace IP address size. +The workspace will be patched to add the additional address space to its vnet. The `address_space` allocation will only take place during the install phase of a deployment, as this is a breaking change to your template you should increment the major version of your template, this means a you must deploy a new resource instead of upgrading an existing one. diff --git a/templates/workspaces/airlock-import-review/porter.yaml b/templates/workspaces/airlock-import-review/porter.yaml index 6acc2e9d7..2726cc5f7 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.16.1 +version: 0.17.0 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 25e7ef3f6..6dd10fc49 100644 --- a/templates/workspaces/airlock-import-review/template_schema.json +++ b/templates/workspaces/airlock-import-review/template_schema.json @@ -33,9 +33,18 @@ "address_space_size": { "type": "string", "title": "Address space size", - "description": "Network address size (small, medium, large or custom) to be used by the workspace.", + "description": "Network address size as a CIDR value or (small /24, medium /22, large /16 or custom with an IP range e.g. 10.2.1.0/25) to be used by the workspace.", "default": "small", "enum": [ + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", "small", "medium", "large", diff --git a/templates/workspaces/base/porter.yaml b/templates/workspaces/base/porter.yaml index 67770d997..5d61ed598 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: 2.10.1 +version: 2.11.0 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 2414ed490..207083fb9 100644 --- a/templates/workspaces/base/template_schema.json +++ b/templates/workspaces/base/template_schema.json @@ -49,9 +49,18 @@ "address_space_size": { "type": "string", "title": "Address space size", - "description": "Network address size (small, medium, large or custom) to be used by the workspace.", + "description": "Network address size as a CIDR value or (small /24, medium /22, large /16 or custom with an IP range e.g. 10.2.1.0/25) to be used by the workspace.", "default": "small", "enum": [ + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", "small", "medium", "large", @@ -381,4 +390,4 @@ "*" ] } -} \ No newline at end of file +} diff --git a/templates/workspaces/unrestricted/porter.yaml b/templates/workspaces/unrestricted/porter.yaml index fb144924f..3a57f155a 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.14.1 +version: 0.15.0 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 5152ecd4a..d2fd162ed 100644 --- a/templates/workspaces/unrestricted/template_schema.json +++ b/templates/workspaces/unrestricted/template_schema.json @@ -41,9 +41,18 @@ "address_space_size": { "type": "string", "title": "Address space size", - "description": "Network address size (small, medium, large or custom) to be used by the workspace.", + "description": "Network address size as a CIDR value or (small /24, medium /22, large /16 or custom with an IP range e.g. 10.2.1.0/25) to be used by the workspace.", "default": "small", "enum": [ + "24", + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", "small", "medium", "large",