diff --git a/data_safe_haven/infrastructure/common/ip_ranges.py b/data_safe_haven/infrastructure/common/ip_ranges.py index f0613e577a..aa201f3878 100644 --- a/data_safe_haven/infrastructure/common/ip_ranges.py +++ b/data_safe_haven/infrastructure/common/ip_ranges.py @@ -14,8 +14,8 @@ class SREIpRanges: apt_proxy_server = vnet.next_subnet(8) clamav_mirror = vnet.next_subnet(8) data_configuration = vnet.next_subnet(8) - data_desired_state = vnet.next_subnet(8) data_private = vnet.next_subnet(8) + desired_state = vnet.next_subnet(8) firewall = vnet.next_subnet(64) # 64 address minimum firewall_management = vnet.next_subnet(64) # 64 address minimum guacamole_containers = vnet.next_subnet(8) diff --git a/data_safe_haven/infrastructure/components/__init__.py b/data_safe_haven/infrastructure/components/__init__.py index cc6bcb15a4..7531491bf8 100644 --- a/data_safe_haven/infrastructure/components/__init__.py +++ b/data_safe_haven/infrastructure/components/__init__.py @@ -4,6 +4,8 @@ LocalDnsRecordProps, MicrosoftSQLDatabaseComponent, MicrosoftSQLDatabaseProps, + NFSV3BlobContainerComponent, + NFSV3BlobContainerProps, PostgresqlDatabaseComponent, PostgresqlDatabaseProps, VMComponent, @@ -20,6 +22,7 @@ ) from .wrapped import ( WrappedLogAnalyticsWorkspace, + WrappedNFSV3StorageAccount, ) __all__ = [ @@ -34,6 +37,9 @@ "LocalDnsRecordProps", "MicrosoftSQLDatabaseComponent", "MicrosoftSQLDatabaseProps", + "NFSV3BlobContainerComponent", + "NFSV3BlobContainerProps", + "WrappedNFSV3StorageAccount", "PostgresqlDatabaseComponent", "PostgresqlDatabaseProps", "SSLCertificate", diff --git a/data_safe_haven/infrastructure/components/composite/__init__.py b/data_safe_haven/infrastructure/components/composite/__init__.py index f111bab028..e4254a50ed 100644 --- a/data_safe_haven/infrastructure/components/composite/__init__.py +++ b/data_safe_haven/infrastructure/components/composite/__init__.py @@ -3,6 +3,7 @@ MicrosoftSQLDatabaseComponent, MicrosoftSQLDatabaseProps, ) +from .nfsv3_blob_container import NFSV3BlobContainerComponent, NFSV3BlobContainerProps from .postgresql_database import PostgresqlDatabaseComponent, PostgresqlDatabaseProps from .virtual_machine import LinuxVMComponentProps, VMComponent @@ -12,6 +13,8 @@ "LocalDnsRecordProps", "MicrosoftSQLDatabaseComponent", "MicrosoftSQLDatabaseProps", + "NFSV3BlobContainerComponent", + "NFSV3BlobContainerProps", "PostgresqlDatabaseComponent", "PostgresqlDatabaseProps", "VMComponent", diff --git a/data_safe_haven/infrastructure/components/composite/nfsv3_blob_container.py b/data_safe_haven/infrastructure/components/composite/nfsv3_blob_container.py new file mode 100644 index 0000000000..98564918a0 --- /dev/null +++ b/data_safe_haven/infrastructure/components/composite/nfsv3_blob_container.py @@ -0,0 +1,75 @@ +from pulumi import ComponentResource, Input, ResourceOptions +from pulumi_azure_native import storage + +from data_safe_haven.infrastructure.components.dynamic.blob_container_acl import ( + BlobContainerAcl, + BlobContainerAclProps, +) + + +class NFSV3BlobContainerProps: + def __init__( + self, + acl_user: Input[str], + acl_group: Input[str], + acl_other: Input[str], + apply_default_permissions: Input[bool], + container_name: Input[str], + resource_group_name: Input[str], + storage_account: Input[storage.StorageAccount], + subscription_name: Input[str], + ): + self.acl_user = acl_user + self.acl_group = acl_group + self.acl_other = acl_other + self.apply_default_permissions = apply_default_permissions + self.container_name = container_name + self.resource_group_name = resource_group_name + self.storage_account = storage_account + self.subscription_name = subscription_name + + +class NFSV3BlobContainerComponent(ComponentResource): + def __init__( + self, + name: str, + props: NFSV3BlobContainerProps, + opts: ResourceOptions | None = None, + ): + super().__init__("dsh:common:NFSV3BlobContainerComponent", name, {}, opts) + child_opts = ResourceOptions.merge(opts, ResourceOptions(parent=self)) + + storage_container = storage.BlobContainer( + f"{self._name}_blob_container_{props.container_name}", + account_name=props.storage_account.name, + container_name=props.container_name, + default_encryption_scope="$account-encryption-key", + deny_encryption_scope_override=False, + public_access=storage.PublicAccess.NONE, + resource_group_name=props.resource_group_name, + opts=ResourceOptions.merge( + child_opts, + ResourceOptions(parent=props.storage_account), + ), + ) + BlobContainerAcl( + f"{storage_container._name}_acl", + BlobContainerAclProps( + acl_user=props.acl_user, + acl_group=props.acl_group, + acl_other=props.acl_other, + apply_default_permissions=props.apply_default_permissions, + container_name=storage_container.name, + resource_group_name=props.resource_group_name, + storage_account_name=props.storage_account.name, + subscription_name=props.subscription_name, + ), + opts=ResourceOptions.merge( + child_opts, + ResourceOptions(parent=props.storage_account), + ), + ) + + self.name = storage_container.name + + self.register_outputs({}) diff --git a/data_safe_haven/infrastructure/components/wrapped/__init__.py b/data_safe_haven/infrastructure/components/wrapped/__init__.py index fc5f8c8f61..ef6e7374d2 100644 --- a/data_safe_haven/infrastructure/components/wrapped/__init__.py +++ b/data_safe_haven/infrastructure/components/wrapped/__init__.py @@ -1,5 +1,7 @@ from .log_analytics_workspace import WrappedLogAnalyticsWorkspace +from .nfsv3_storage_account import WrappedNFSV3StorageAccount __all__ = [ + "WrappedNFSV3StorageAccount", "WrappedLogAnalyticsWorkspace", ] diff --git a/data_safe_haven/infrastructure/components/wrapped/nfsv3_storage_account.py b/data_safe_haven/infrastructure/components/wrapped/nfsv3_storage_account.py new file mode 100644 index 0000000000..181839e71d --- /dev/null +++ b/data_safe_haven/infrastructure/components/wrapped/nfsv3_storage_account.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping, Sequence + +from pulumi import Input, Output, ResourceOptions +from pulumi_azure_native import storage + +from data_safe_haven.external import AzureIPv4Range + + +class WrappedNFSV3StorageAccount(storage.StorageAccount): + encryption_args = storage.EncryptionArgs( + key_source=storage.KeySource.MICROSOFT_STORAGE, + services=storage.EncryptionServicesArgs( + blob=storage.EncryptionServiceArgs( + enabled=True, key_type=storage.KeyType.ACCOUNT + ), + file=storage.EncryptionServiceArgs( + enabled=True, key_type=storage.KeyType.ACCOUNT + ), + ), + ) + + def __init__( + self, + resource_name: str, + *, + account_name: Input[str], + allowed_ip_addresses: Input[Sequence[str]], + location: Input[str], + resource_group_name: Input[str], + subnet_id: Input[str], + opts: ResourceOptions, + tags: Input[Mapping[str, Input[str]]], + ): + self.resource_group_name_ = Output.from_input(resource_group_name) + super().__init__( + resource_name, + account_name=account_name, + enable_https_traffic_only=True, + enable_nfs_v3=True, + encryption=self.encryption_args, + is_hns_enabled=True, + kind=storage.Kind.BLOCK_BLOB_STORAGE, + location=location, + minimum_tls_version=storage.MinimumTlsVersion.TLS1_2, + network_rule_set=storage.NetworkRuleSetArgs( + bypass=storage.Bypass.AZURE_SERVICES, + default_action=storage.DefaultAction.DENY, + ip_rules=Output.from_input(allowed_ip_addresses).apply( + lambda ip_ranges: [ + storage.IPRuleArgs( + action=storage.Action.ALLOW, + i_p_address_or_range=str(ip_address), + ) + for ip_range in sorted(ip_ranges) + for ip_address in AzureIPv4Range.from_cidr(ip_range).all_ips() + ] + ), + virtual_network_rules=[ + storage.VirtualNetworkRuleArgs( + virtual_network_resource_id=subnet_id, + ) + ], + ), + resource_group_name=resource_group_name, + sku=storage.SkuArgs(name=storage.SkuName.PREMIUM_ZRS), + opts=opts, + tags=tags, + ) diff --git a/data_safe_haven/infrastructure/programs/declarative_sre.py b/data_safe_haven/infrastructure/programs/declarative_sre.py index 1db4436c1f..483d21355b 100644 --- a/data_safe_haven/infrastructure/programs/declarative_sre.py +++ b/data_safe_haven/infrastructure/programs/declarative_sre.py @@ -15,6 +15,7 @@ from .sre.backup import SREBackupComponent, SREBackupProps from .sre.clamav_mirror import SREClamAVMirrorComponent, SREClamAVMirrorProps from .sre.data import SREDataComponent, SREDataProps +from .sre.desired_state import SREDesiredStateComponent, SREDesiredStateProps from .sre.dns_server import SREDnsServerComponent, SREDnsServerProps from .sre.firewall import SREFirewallComponent, SREFirewallProps from .sre.identity import SREIdentityComponent, SREIdentityProps @@ -184,7 +185,6 @@ def __call__(self) -> None: storage_quota_gb_home=self.config.sre.storage_quota_gb.home, storage_quota_gb_shared=self.config.sre.storage_quota_gb.shared, subnet_data_configuration=networking.subnet_data_configuration, - subnet_data_desired_state=networking.subnet_data_desired_state, subnet_data_private=networking.subnet_data_private, subscription_id=self.config.azure.subscription_id, subscription_name=self.context.subscription_name, @@ -338,17 +338,15 @@ def __call__(self) -> None: tags=self.tags, ) - # Deploy workspaces - workspaces = SREWorkspacesComponent( - "sre_workspaces", + # Deploy desired state + desired_state = SREDesiredStateComponent( + "sre_desired_state", self.stack_name, - SREWorkspacesProps( - admin_password=data.password_workspace_admin, - apt_proxy_server_hostname=apt_proxy_server.hostname, + SREDesiredStateProps( + admin_ip_addresses=self.config.sre.admin_ip_addresses, clamav_mirror_hostname=clamav_mirror.hostname, - data_collection_rule_id=monitoring.data_collection_rule_vms.id, - data_collection_endpoint_id=monitoring.data_collection_endpoint.id, database_service_admin_password=data.password_database_service_admin, + dns_private_zones=dns.private_zones, gitea_hostname=user_services.gitea_server.hostname, hedgedoc_hostname=user_services.hedgedoc_server.hostname, ldap_group_filter=ldap_group_filter, @@ -358,11 +356,27 @@ def __call__(self) -> None: ldap_user_filter=ldap_user_filter, ldap_user_search_base=ldap_user_search_base, location=self.config.azure.location, + resource_group=resource_group, + software_repository_hostname=user_services.software_repositories.hostname, + subnet_desired_state=networking.subnet_desired_state, + subscription_name=self.context.subscription_name, + ), + ) + + # Deploy workspaces + workspaces = SREWorkspacesComponent( + "sre_workspaces", + self.stack_name, + SREWorkspacesProps( + admin_password=data.password_workspace_admin, + apt_proxy_server_hostname=apt_proxy_server.hostname, + data_collection_rule_id=monitoring.data_collection_rule_vms.id, + data_collection_endpoint_id=monitoring.data_collection_endpoint.id, + location=self.config.azure.location, maintenance_configuration_id=monitoring.maintenance_configuration.id, resource_group_name=resource_group.name, - software_repository_hostname=user_services.software_repositories.hostname, sre_name=self.config.name, - storage_account_data_desired_state_name=data.storage_account_data_desired_state_name, + storage_account_desired_state_name=desired_state.storage_account_name, storage_account_data_private_user_name=data.storage_account_data_private_user_name, storage_account_data_private_sensitive_name=data.storage_account_data_private_sensitive_name, subnet_workspaces=networking.subnet_workspaces, diff --git a/data_safe_haven/infrastructure/programs/sre/data.py b/data_safe_haven/infrastructure/programs/sre/data.py index 21e8f4c29b..9e18666277 100644 --- a/data_safe_haven/infrastructure/programs/sre/data.py +++ b/data_safe_haven/infrastructure/programs/sre/data.py @@ -4,7 +4,7 @@ from typing import ClassVar import pulumi_random -from pulumi import ComponentResource, FileAsset, Input, Output, ResourceOptions +from pulumi import ComponentResource, Input, Output, ResourceOptions from pulumi_azure_native import ( authorization, keyvault, @@ -29,12 +29,12 @@ get_name_from_rg, ) from data_safe_haven.infrastructure.components import ( - BlobContainerAcl, - BlobContainerAclProps, + NFSV3BlobContainerComponent, + NFSV3BlobContainerProps, SSLCertificate, SSLCertificateProps, + WrappedNFSV3StorageAccount, ) -from data_safe_haven.resources import resources_path from data_safe_haven.types import AzureDnsZoneNames @@ -56,7 +56,6 @@ def __init__( storage_quota_gb_home: Input[int], storage_quota_gb_shared: Input[int], subnet_data_configuration: Input[network.GetSubnetResult], - subnet_data_desired_state: Input[network.GetSubnetResult], subnet_data_private: Input[network.GetSubnetResult], subscription_id: Input[str], subscription_name: Input[str], @@ -86,9 +85,6 @@ def __init__( self.subnet_data_configuration_id = Output.from_input( subnet_data_configuration ).apply(get_id_from_subnet) - self.subnet_data_desired_state_id = Output.from_input( - subnet_data_desired_state - ).apply(get_id_from_subnet) self.subnet_data_private_id = Output.from_input(subnet_data_private).apply( get_id_from_subnet ) @@ -461,271 +457,52 @@ def __init__( child_opts, ResourceOptions(parent=storage_account_data_configuration) ), ) - - # Deploy desired state storage account - # - This holds the /desired_state container that is mounted by workspaces - # - Azure blobs have worse NFS support but can be accessed with Azure Storage Explorer - storage_account_data_desired_state = storage.StorageAccount( - f"{self._name}_storage_account_data_desired_state", - # Storage account names have a maximum of 24 characters - account_name=alphanumeric( - f"{''.join(truncate_tokens(stack_name.split('-'), 11))}desiredstate{sha256hash(self._name)}" - )[:24], - enable_https_traffic_only=True, - enable_nfs_v3=True, - encryption=storage.EncryptionArgs( - key_source=storage.KeySource.MICROSOFT_STORAGE, - services=storage.EncryptionServicesArgs( - blob=storage.EncryptionServiceArgs( - enabled=True, key_type=storage.KeyType.ACCOUNT - ), - file=storage.EncryptionServiceArgs( - enabled=True, key_type=storage.KeyType.ACCOUNT - ), - ), - ), - kind=storage.Kind.BLOCK_BLOB_STORAGE, - is_hns_enabled=True, - location=props.location, - minimum_tls_version=storage.MinimumTlsVersion.TLS1_2, - network_rule_set=storage.NetworkRuleSetArgs( - bypass=storage.Bypass.AZURE_SERVICES, - default_action=storage.DefaultAction.DENY, - ip_rules=Output.from_input(props.data_configuration_ip_addresses).apply( - lambda ip_ranges: [ - storage.IPRuleArgs( - action=storage.Action.ALLOW, - i_p_address_or_range=str(ip_address), - ) - for ip_range in sorted(ip_ranges) - for ip_address in AzureIPv4Range.from_cidr(ip_range).all_ips() - ] - ), - virtual_network_rules=[ - storage.VirtualNetworkRuleArgs( - virtual_network_resource_id=props.subnet_data_desired_state_id, - ) - ], - ), - resource_group_name=props.resource_group_name, - sku=storage.SkuArgs(name=storage.SkuName.PREMIUM_ZRS), - opts=child_opts, - tags=child_tags, - ) - # Deploy desired state share - container_desired_state = storage.BlobContainer( - f"{self._name}_blob_desired_state", - account_name=storage_account_data_desired_state.name, - container_name="desiredstate", - default_encryption_scope="$account-encryption-key", - deny_encryption_scope_override=False, - public_access=storage.PublicAccess.NONE, - resource_group_name=props.resource_group_name, - opts=ResourceOptions.merge( - child_opts, - ResourceOptions(parent=storage_account_data_desired_state), - ), - ) - # Set storage container ACLs - BlobContainerAcl( - f"{container_desired_state._name}_acl", - BlobContainerAclProps( - acl_user="r-x", - acl_group="r-x", - acl_other="r-x", - # ensure that the above permissions are also set on any newly created - # files (eg. with Azure Storage Explorer) - apply_default_permissions=True, - container_name=container_desired_state.name, - resource_group_name=props.resource_group_name, - storage_account_name=storage_account_data_desired_state.name, - subscription_name=props.subscription_name, - ), - opts=ResourceOptions.merge( - child_opts, ResourceOptions(parent=container_desired_state) - ), - ) - # Create file assets to upload - desired_state_directory = (resources_path / "workspace" / "ansible").absolute() - files_desired_state = [ - ( - FileAsset(str(file_path)), - file_path.name, - str(file_path.relative_to(desired_state_directory)), - ) - for file_path in sorted(desired_state_directory.rglob("*")) - if file_path.is_file() and not file_path.name.startswith(".") - ] - # Upload file assets to desired state container - for file_asset, file_name, file_path in files_desired_state: - storage.Blob( - f"{container_desired_state._name}_blob_{file_name}", - account_name=storage_account_data_desired_state.name, - blob_name=file_path, - container_name=container_desired_state.name, - resource_group_name=props.resource_group_name, - source=file_asset, - ) - # Set up a private endpoint for the desired state storage account - storage_account_data_desired_state_endpoint = network.PrivateEndpoint( - f"{storage_account_data_desired_state._name}_private_endpoint", - location=props.location, - private_endpoint_name=f"{stack_name}-pep-storage-account-data-desired-state", - private_link_service_connections=[ - network.PrivateLinkServiceConnectionArgs( - group_ids=["blob"], - name=f"{stack_name}-cnxn-pep-storage-account-data-private-sensitive", - private_link_service_id=storage_account_data_desired_state.id, - ) - ], - resource_group_name=props.resource_group_name, - subnet=network.SubnetArgs(id=props.subnet_data_desired_state_id), - opts=ResourceOptions.merge( - child_opts, - ResourceOptions( - ignore_changes=["custom_dns_configs"], - parent=storage_account_data_desired_state, - ), - ), - tags=child_tags, - ) - # Add a private DNS record for each desired state endpoint custom DNS config - network.PrivateDnsZoneGroup( - f"{storage_account_data_desired_state._name}_private_dns_zone_group", - private_dns_zone_configs=[ - network.PrivateDnsZoneConfigArgs( - name=replace_separators( - f"{stack_name}-storage-account-data-desired-state-to-{dns_zone_name}", - "-", - ), - private_dns_zone_id=props.dns_private_zones[dns_zone_name].id, - ) - for dns_zone_name in AzureDnsZoneNames.STORAGE_ACCOUNT - ], - private_dns_zone_group_name=f"{stack_name}-dzg-storage-account-data-desired-state", - private_endpoint_name=storage_account_data_desired_state_endpoint.name, - resource_group_name=props.resource_group_name, - opts=ResourceOptions.merge( - child_opts, - ResourceOptions(parent=storage_account_data_desired_state), - ), - ) - # Deploy sensitive data blob storage account - # - This holds the /data and /output containers that are mounted by workspaces + # - This holds the /mnt/input and /mnt/output containers that are mounted by workspaces # - Azure blobs have worse NFS support but can be accessed with Azure Storage Explorer - storage_account_data_private_sensitive = storage.StorageAccount( + storage_account_data_private_sensitive = WrappedNFSV3StorageAccount( f"{self._name}_storage_account_data_private_sensitive", # Storage account names have a maximum of 24 characters account_name=alphanumeric( f"{''.join(truncate_tokens(stack_name.split('-'), 11))}sensitivedata{sha256hash(self._name)}" )[:24], - enable_https_traffic_only=True, - enable_nfs_v3=True, - encryption=storage.EncryptionArgs( - key_source=storage.KeySource.MICROSOFT_STORAGE, - services=storage.EncryptionServicesArgs( - blob=storage.EncryptionServiceArgs( - enabled=True, key_type=storage.KeyType.ACCOUNT - ), - file=storage.EncryptionServiceArgs( - enabled=True, key_type=storage.KeyType.ACCOUNT - ), - ), - ), - kind=storage.Kind.BLOCK_BLOB_STORAGE, - is_hns_enabled=True, + allowed_ip_addresses=props.data_private_sensitive_ip_addresses, location=props.location, - minimum_tls_version=storage.MinimumTlsVersion.TLS1_2, - network_rule_set=storage.NetworkRuleSetArgs( - bypass=storage.Bypass.AZURE_SERVICES, - default_action=storage.DefaultAction.DENY, - ip_rules=Output.from_input( - props.data_private_sensitive_ip_addresses - ).apply( - lambda ip_ranges: [ - storage.IPRuleArgs( - action=storage.Action.ALLOW, - i_p_address_or_range=str(ip_address), - ) - for ip_range in sorted(ip_ranges) - for ip_address in AzureIPv4Range.from_cidr(ip_range).all_ips() - ] - ), - virtual_network_rules=[ - storage.VirtualNetworkRuleArgs( - virtual_network_resource_id=props.subnet_data_private_id, - ) - ], - ), + subnet_id=props.subnet_data_private_id, resource_group_name=props.resource_group_name, - sku=storage.SkuArgs(name=storage.SkuName.PREMIUM_ZRS), opts=child_opts, tags=child_tags, ) # Deploy storage containers - storage_container_egress = storage.BlobContainer( + NFSV3BlobContainerComponent( f"{self._name}_blob_egress", - account_name=storage_account_data_private_sensitive.name, - container_name="egress", - default_encryption_scope="$account-encryption-key", - deny_encryption_scope_override=False, - public_access=storage.PublicAccess.NONE, - resource_group_name=props.resource_group_name, - opts=ResourceOptions.merge( - child_opts, - ResourceOptions(parent=storage_account_data_private_sensitive), - ), - ) - storage_container_ingress = storage.BlobContainer( - f"{self._name}_blob_ingress", - account_name=storage_account_data_private_sensitive.name, - container_name="ingress", - default_encryption_scope="$account-encryption-key", - deny_encryption_scope_override=False, - public_access=storage.PublicAccess.NONE, - resource_group_name=props.resource_group_name, - opts=ResourceOptions.merge( - child_opts, - ResourceOptions(parent=storage_account_data_private_sensitive), - ), - ) - # Set storage container ACLs - BlobContainerAcl( - f"{storage_container_egress._name}_acl", - BlobContainerAclProps( + NFSV3BlobContainerProps( acl_user="rwx", acl_group="rwx", acl_other="rwx", # due to an Azure bug `apply_default_permissions=True` also gives user # 65533 ownership of the fileshare (preventing use inside the SRE) apply_default_permissions=False, - container_name=storage_container_egress.name, + container_name="egress", resource_group_name=props.resource_group_name, - storage_account_name=storage_account_data_private_sensitive.name, + storage_account=storage_account_data_private_sensitive, subscription_name=props.subscription_name, ), - opts=ResourceOptions.merge( - child_opts, ResourceOptions(parent=storage_container_egress) - ), ) - BlobContainerAcl( - f"{storage_container_ingress._name}_acl", - BlobContainerAclProps( + NFSV3BlobContainerComponent( + f"{self._name}_blob_ingress", + NFSV3BlobContainerProps( acl_user="rwx", acl_group="r-x", acl_other="r-x", # ensure that the above permissions are also set on any newly created # files (eg. with Azure Storage Explorer) apply_default_permissions=True, - container_name=storage_container_ingress.name, + container_name="ingress", resource_group_name=props.resource_group_name, - storage_account_name=storage_account_data_private_sensitive.name, + storage_account=storage_account_data_private_sensitive, subscription_name=props.subscription_name, ), - opts=ResourceOptions.merge( - child_opts, ResourceOptions(parent=storage_container_ingress) - ), ) # Set up a private endpoint for the sensitive data storage account storage_account_data_private_sensitive_endpoint = network.PrivateEndpoint( @@ -792,7 +569,7 @@ def __init__( ) # Deploy data_private_user files storage account - # - This holds the /home and /shared containers that are mounted by workspaces + # - This holds the /home and /mnt/shared containers that are mounted by workspaces # - Azure Files has better NFS support but cannot be accessed with Azure Storage Explorer # - Allows root-squashing to be configured # From https://learn.microsoft.com/en-us/azure/storage/files/files-nfs-protocol @@ -922,9 +699,6 @@ def __init__( self.storage_account_data_configuration_name = ( storage_account_data_configuration.name ) - self.storage_account_data_desired_state_name = ( - storage_account_data_desired_state.name - ) self.managed_identity = identity_key_vault_reader self.password_nexus_admin = Output.secret(password_nexus_admin.result) self.password_database_service_admin = Output.secret( diff --git a/data_safe_haven/infrastructure/programs/sre/desired_state.py b/data_safe_haven/infrastructure/programs/sre/desired_state.py new file mode 100644 index 0000000000..73466d6c5b --- /dev/null +++ b/data_safe_haven/infrastructure/programs/sre/desired_state.py @@ -0,0 +1,224 @@ +"""Pulumi component for SRE desired state""" + +from collections.abc import Mapping, Sequence + +import yaml +from pulumi import ( + ComponentResource, + FileAsset, + Input, + Output, + ResourceOptions, + StringAsset, +) +from pulumi_azure_native import ( + network, + resources, + storage, +) + +from data_safe_haven.functions import ( + alphanumeric, + replace_separators, + sha256hash, + truncate_tokens, +) +from data_safe_haven.infrastructure.common import ( + get_id_from_rg, + get_id_from_subnet, + get_name_from_rg, +) +from data_safe_haven.infrastructure.components import ( + NFSV3BlobContainerComponent, + NFSV3BlobContainerProps, + WrappedNFSV3StorageAccount, +) +from data_safe_haven.resources import resources_path +from data_safe_haven.types import AzureDnsZoneNames + + +class SREDesiredStateProps: + """Properties for SREDesiredStateComponent""" + + def __init__( + self, + admin_ip_addresses: Input[Sequence[str]], + clamav_mirror_hostname: Input[str], + database_service_admin_password: Input[str], + dns_private_zones: Input[dict[str, network.PrivateZone]], + gitea_hostname: Input[str], + hedgedoc_hostname: Input[str], + ldap_group_filter: Input[str], + ldap_group_search_base: Input[str], + ldap_server_hostname: Input[str], + ldap_server_port: Input[int], + ldap_user_filter: Input[str], + ldap_user_search_base: Input[str], + location: Input[str], + resource_group: Input[resources.ResourceGroup], + software_repository_hostname: Input[str], + subscription_name: Input[str], + subnet_desired_state: Input[network.GetSubnetResult], + ) -> None: + self.admin_ip_addresses = admin_ip_addresses + self.clamav_mirror_hostname = clamav_mirror_hostname + self.database_service_admin_password = database_service_admin_password + self.dns_private_zones = dns_private_zones + self.gitea_hostname = gitea_hostname + self.hedgedoc_hostname = hedgedoc_hostname + self.ldap_group_filter = ldap_group_filter + self.ldap_group_search_base = ldap_group_search_base + self.ldap_server_hostname = ldap_server_hostname + self.ldap_server_port = Output.from_input(ldap_server_port).apply(str) + self.ldap_user_filter = ldap_user_filter + self.ldap_user_search_base = ldap_user_search_base + self.location = location + self.resource_group_id = Output.from_input(resource_group).apply(get_id_from_rg) + self.resource_group_name = Output.from_input(resource_group).apply( + get_name_from_rg + ) + self.software_repository_hostname = software_repository_hostname + self.subnet_desired_state_id = Output.from_input(subnet_desired_state).apply( + get_id_from_subnet + ) + self.subscription_name = subscription_name + + +class SREDesiredStateComponent(ComponentResource): + """Deploy SRE desired state with Pulumi""" + + def __init__( + self, + name: str, + stack_name: str, + props: SREDesiredStateProps, + opts: ResourceOptions | None = None, + tags: Input[Mapping[str, Input[str]]] | None = None, + ) -> None: + super().__init__("dsh:sre:DesiredStateComponent", name, {}, opts) + child_opts = ResourceOptions.merge(opts, ResourceOptions(parent=self)) + child_tags = {"component": "data"} | (tags if tags else {}) + + # Deploy desired state storage account + # - This holds the /var/local/ansible container that is mounted by workspaces + # - Azure blobs have worse NFS support but can be accessed with Azure Storage Explorer + storage_account = WrappedNFSV3StorageAccount( + f"{self._name}_storage_account", + account_name=alphanumeric( + f"{''.join(truncate_tokens(stack_name.split('-'), 11))}desiredstate{sha256hash(self._name)}" + )[:24], + allowed_ip_addresses=props.admin_ip_addresses, + location=props.location, + resource_group_name=props.resource_group_name, + subnet_id=props.subnet_desired_state_id, + opts=child_opts, + tags=child_tags, + ) + # Deploy desired state share + container_desired_state = NFSV3BlobContainerComponent( + f"{self._name}_blob_desired_state", + NFSV3BlobContainerProps( + acl_user="r-x", + acl_group="r-x", + acl_other="r-x", + # ensure that the above permissions are also set on any newly created + # files (eg. with Azure Storage Explorer) + apply_default_permissions=True, + container_name="desiredstate", + resource_group_name=props.resource_group_name, + storage_account=storage_account, + subscription_name=props.subscription_name, + ), + ) + # Create file assets to upload + desired_state_directory = (resources_path / "workspace" / "ansible").absolute() + files_desired_state = [ + ( + FileAsset(str(file_path)), + file_path.name, + str(file_path.relative_to(desired_state_directory)), + ) + for file_path in sorted(desired_state_directory.rglob("*")) + if file_path.is_file() and not file_path.name.startswith(".") + ] + # Upload file assets to desired state container + for file_asset, file_name, file_path in files_desired_state: + storage.Blob( + f"{container_desired_state._name}_blob_{file_name}", + account_name=storage_account.name, + blob_name=file_path, + container_name=container_desired_state.name, + resource_group_name=props.resource_group_name, + source=file_asset, + ) + # Upload ansible vars file + storage.Blob( + f"{container_desired_state._name}_blob_pulumi_vars", + account_name=storage_account.name, + blob_name="vars/pulumi_vars.yaml", + container_name=container_desired_state.name, + resource_group_name=props.resource_group_name, + source=Output.all( + clamav_mirror_hostname=props.clamav_mirror_hostname, + database_service_admin_password=props.database_service_admin_password, + gitea_hostname=props.gitea_hostname, + hedgedoc_hostname=props.hedgedoc_hostname, + ldap_group_filter=props.ldap_group_filter, + ldap_group_search_base=props.ldap_group_search_base, + ldap_server_hostname=props.ldap_server_hostname, + ldap_server_port=props.ldap_server_port, + ldap_user_filter=props.ldap_user_filter, + ldap_user_search_base=props.ldap_user_search_base, + software_repository_hostname=props.software_repository_hostname, + ).apply(lambda kwargs: StringAsset(self.ansible_vars_file(**kwargs))), + ) + # Set up a private endpoint for the desired state storage account + storage_account_endpoint = network.PrivateEndpoint( + f"{storage_account._name}_private_endpoint", + location=props.location, + private_endpoint_name=f"{stack_name}-pep-storage-account-desired-state", + private_link_service_connections=[ + network.PrivateLinkServiceConnectionArgs( + group_ids=["blob"], + name=f"{stack_name}-cnxn-pep-storage-account-desired-state", + private_link_service_id=storage_account.id, + ) + ], + resource_group_name=props.resource_group_name, + subnet=network.SubnetArgs(id=props.subnet_desired_state_id), + opts=ResourceOptions.merge( + child_opts, + ResourceOptions( + ignore_changes=["custom_dns_configs"], + parent=storage_account, + ), + ), + tags=child_tags, + ) + # Add a private DNS record for each desired state endpoint custom DNS config + network.PrivateDnsZoneGroup( + f"{storage_account._name}_private_dns_zone_group", + private_dns_zone_configs=[ + network.PrivateDnsZoneConfigArgs( + name=replace_separators( + f"{stack_name}-storage-account-desired-state-to-{dns_zone_name}", + "-", + ), + private_dns_zone_id=props.dns_private_zones[dns_zone_name].id, + ) + for dns_zone_name in AzureDnsZoneNames.STORAGE_ACCOUNT + ], + private_dns_zone_group_name=f"{stack_name}-dzg-storage-account-desired-state", + private_endpoint_name=storage_account_endpoint.name, + resource_group_name=props.resource_group_name, + opts=ResourceOptions.merge( + child_opts, + ResourceOptions(parent=storage_account), + ), + ) + + self.storage_account_name = storage_account.name + + @staticmethod + def ansible_vars_file(**kwargs: str) -> str: + return yaml.safe_dump(kwargs, explicit_start=True, indent=2) diff --git a/data_safe_haven/infrastructure/programs/sre/networking.py b/data_safe_haven/infrastructure/programs/sre/networking.py index 5578d791a0..c11d89bc17 100644 --- a/data_safe_haven/infrastructure/programs/sre/networking.py +++ b/data_safe_haven/infrastructure/programs/sre/networking.py @@ -483,10 +483,10 @@ def __init__( opts=child_opts, tags=child_tags, ) - nsg_data_desired_state = network.NetworkSecurityGroup( - f"{self._name}_nsg_data_desired_state", + nsg_desired_state = network.NetworkSecurityGroup( + f"{self._name}_nsg_desired_state", location=props.location, - network_security_group_name=f"{stack_name}-nsg-data-desired-state", + network_security_group_name=f"{stack_name}-nsg-desired-state", resource_group_name=props.resource_group_name, security_rules=[ # Inbound @@ -1454,7 +1454,7 @@ def __init__( network.SecurityRuleArgs( access=network.SecurityRuleAccess.ALLOW, description="Allow outbound connections to desired state data endpoints.", - destination_address_prefix=SREIpRanges.data_desired_state.prefix, + destination_address_prefix=SREIpRanges.desired_state.prefix, destination_port_range="*", direction=network.SecurityRuleDirection.OUTBOUND, name="AllowDataDesiredStateEndpointsOutbound", @@ -1558,7 +1558,7 @@ def __init__( subnet_apt_proxy_server_name = "AptProxyServerSubnet" subnet_clamav_mirror_name = "ClamAVMirrorSubnet" subnet_data_configuration_name = "DataConfigurationSubnet" - subnet_data_desired_state_name = "DataDesiredStateSubnet" + subnet_desired_state_name = "DataDesiredStateSubnet" subnet_data_private_name = "DataPrivateSubnet" subnet_firewall_name = "AzureFirewallSubnet" subnet_firewall_management_name = "AzureFirewallManagementSubnet" @@ -1643,10 +1643,10 @@ def __init__( ), # Desired state data subnet network.SubnetArgs( - address_prefix=SREIpRanges.data_desired_state.prefix, - name=subnet_data_desired_state_name, + address_prefix=SREIpRanges.desired_state.prefix, + name=subnet_desired_state_name, network_security_group=network.NetworkSecurityGroupArgs( - id=nsg_data_desired_state.id + id=nsg_desired_state.id ), route_table=network.RouteTableArgs(id=route_table.id), service_endpoints=[ @@ -1966,13 +1966,13 @@ def __init__( resource_group_name=props.resource_group_name, virtual_network_name=sre_virtual_network.name, ) - self.subnet_data_desired_state = network.get_subnet_output( - subnet_name=subnet_data_desired_state_name, + self.subnet_desired_state = network.get_subnet_output( + subnet_name=subnet_desired_state_name, resource_group_name=props.resource_group_name, virtual_network_name=sre_virtual_network.name, ) - self.subnet_data_desired_state = network.get_subnet_output( - subnet_name=subnet_data_desired_state_name, + self.subnet_desired_state = network.get_subnet_output( + subnet_name=subnet_desired_state_name, resource_group_name=props.resource_group_name, virtual_network_name=sre_virtual_network.name, ) diff --git a/data_safe_haven/infrastructure/programs/sre/workspaces.py b/data_safe_haven/infrastructure/programs/sre/workspaces.py index b48de97668..d6c22bce53 100644 --- a/data_safe_haven/infrastructure/programs/sre/workspaces.py +++ b/data_safe_haven/infrastructure/programs/sre/workspaces.py @@ -24,24 +24,13 @@ def __init__( self, admin_password: Input[str], apt_proxy_server_hostname: Input[str], - clamav_mirror_hostname: Input[str], data_collection_endpoint_id: Input[str], data_collection_rule_id: Input[str], - database_service_admin_password: Input[str], - gitea_hostname: Input[str], - hedgedoc_hostname: Input[str], - ldap_group_filter: Input[str], - ldap_group_search_base: Input[str], - ldap_server_hostname: Input[str], - ldap_server_port: Input[int], - ldap_user_filter: Input[str], - ldap_user_search_base: Input[str], location: Input[str], maintenance_configuration_id: Input[str], resource_group_name: Input[str], - software_repository_hostname: Input[str], sre_name: Input[str], - storage_account_data_desired_state_name: Input[str], + storage_account_desired_state_name: Input[str], storage_account_data_private_sensitive_name: Input[str], storage_account_data_private_user_name: Input[str], subnet_workspaces: Input[network.GetSubnetResult], @@ -52,26 +41,13 @@ def __init__( self.admin_password = Output.secret(admin_password) self.admin_username = "dshadmin" self.apt_proxy_server_hostname = apt_proxy_server_hostname - self.clamav_mirror_hostname = clamav_mirror_hostname self.data_collection_rule_id = data_collection_rule_id self.data_collection_endpoint_id = data_collection_endpoint_id - self.database_service_admin_password = database_service_admin_password - self.gitea_hostname = gitea_hostname - self.hedgedoc_hostname = hedgedoc_hostname - self.ldap_group_filter = ldap_group_filter - self.ldap_group_search_base = ldap_group_search_base - self.ldap_server_hostname = ldap_server_hostname - self.ldap_server_port = Output.from_input(ldap_server_port).apply(str) - self.ldap_user_filter = ldap_user_filter - self.ldap_user_search_base = ldap_user_search_base self.location = location self.maintenance_configuration_id = maintenance_configuration_id self.resource_group_name = resource_group_name - self.software_repository_hostname = software_repository_hostname self.sre_name = sre_name - self.storage_account_data_desired_state_name = ( - storage_account_data_desired_state_name - ) + self.storage_account_desired_state_name = storage_account_desired_state_name self.storage_account_data_private_user_name = ( storage_account_data_private_user_name ) @@ -118,18 +94,7 @@ def __init__( # Load cloud-init file cloudinit = Output.all( apt_proxy_server_hostname=props.apt_proxy_server_hostname, - clamav_mirror_hostname=props.clamav_mirror_hostname, - database_service_admin_password=props.database_service_admin_password, - gitea_hostname=props.gitea_hostname, - hedgedoc_hostname=props.hedgedoc_hostname, - ldap_group_filter=props.ldap_group_filter, - ldap_group_search_base=props.ldap_group_search_base, - ldap_server_hostname=props.ldap_server_hostname, - ldap_server_port=props.ldap_server_port, - ldap_user_filter=props.ldap_user_filter, - ldap_user_search_base=props.ldap_user_search_base, - software_repository_hostname=props.software_repository_hostname, - storage_account_data_desired_state_name=props.storage_account_data_desired_state_name, + storage_account_desired_state_name=props.storage_account_desired_state_name, storage_account_data_private_user_name=props.storage_account_data_private_user_name, storage_account_data_private_sensitive_name=props.storage_account_data_private_sensitive_name, ).apply(lambda kwargs: self.template_cloudinit(**kwargs)) diff --git a/data_safe_haven/resources/workspace/ansible/desired_state.yaml b/data_safe_haven/resources/workspace/ansible/desired_state.yaml index 410efcc348..e70bb1c54e 100644 --- a/data_safe_haven/resources/workspace/ansible/desired_state.yaml +++ b/data_safe_haven/resources/workspace/ansible/desired_state.yaml @@ -2,6 +2,8 @@ - name: Desired state configuration hosts: localhost become: true + vars_files: + - vars/pulumi_vars.yaml tasks: - name: Update package cache @@ -27,7 +29,7 @@ tags: apt ansible.builtin.script: executable: /bin/bash - cmd: "/desired_state/install_deb.sh {{ item.source }} {{ item.filename }} {{ item.sha256 }}" + cmd: "/var/local/ansible/install_deb.sh {{ item.source }} {{ item.filename }} {{ item.sha256 }}" creates: "{{ item.creates }}" loop: "{{ deb_packages[ansible_facts.distribution_release] }}" @@ -85,6 +87,12 @@ regexp: '^(passwd|group|shadow)(:.*)(?