diff --git a/azure-py-virtual-data-center/.gitignore b/azure-py-virtual-data-center/.gitignore new file mode 100644 index 000000000..894a640fc --- /dev/null +++ b/azure-py-virtual-data-center/.gitignore @@ -0,0 +1,3 @@ +*.pyc +venv/ +__pycache__ diff --git a/azure-py-virtual-data-center/Pulumi.yaml b/azure-py-virtual-data-center/Pulumi.yaml new file mode 100644 index 000000000..15d178bf8 --- /dev/null +++ b/azure-py-virtual-data-center/Pulumi.yaml @@ -0,0 +1,49 @@ +name: azure-py-vdc +runtime: python +description: A minimal Azure Virtual Data Center described in Python +template: + config: + azure:environment: + description: Azure environment to use (`public`, `usgovernment`, `german`, `china`) + default: public + azure:location: + description: Azure region to use (e.g. `australiaeast` or `australiasoutheast`) + default: australiasoutheast + azure-py-vdc:dmz_ar: + description: Address range for hub DMZ subnet (must be within fwz_as) + default: 192.168.100.128/25 + azure-py-vdc:fwm_ar: + description: Address range for hub AzureFirewallManagementSubnet (optional - /26 within fwz_as) + azure-py-vdc:fws_ar: + description: Address range for hub AzureFirewallSubnet (/26 within fwz_as) + default: 192.168.100.0/26 + azure-py-vdc:fwz_as: + description: Address space for hub containing dmz_ar, fwm_ar (optional) and fws_ar + default: 192.168.100.0/24 + azure-py-vdc:gws_ar: + description: Address range for hub GatewaySubnet (/27 or larger within hub_as) + default: 10.100.0.0/26 + azure-py-vdc:hbs_ar: + description: Address range for hub AzureBastionSubnet (optional - /27 within hub_as) + azure-py-vdc:hub_ar: + description: Address range for starting subnet in the hub (optional - within hub_as) + azure-py-vdc:hub_as: + description: Address space for hub containing hbs_ar, hub_ar and remaining subnets + default: 10.100.0.0/16 + azure-py-vdc:hub_stem: + description: Short name for the hub that will appear in resource names (<4 chars) + default: hub + azure-py-vdc:org: + description: Pulumi organization in which this project resides (from app.pulumi.com) + azure-py-vdc:peer: + description: Another stack in same organization and project to peer hubs with (optional) + azure-py-vdc:sbs_ar: + description: Address range for spoke AzureBastionSubnet (optional - /27 within spoke_as) + azure-py-vdc:spoke_ar: + description: Address range for starting subnet in the spoke (optional - within spoke_as) + azure-py-vdc:spoke_as: + description: Address space for spoke containing sbs_ar, spoke_ar and remaining subnets + default: 10.101.0.0/16 + azure-py-vdc:spoke_stem: + description: Short name for spoke that will appear in resource names (<6 chars) + default: spoke diff --git a/azure-py-virtual-data-center/README.md b/azure-py-virtual-data-center/README.md new file mode 100644 index 000000000..e23c364c0 --- /dev/null +++ b/azure-py-virtual-data-center/README.md @@ -0,0 +1,217 @@ +[![Deploy](https://get.pulumi.com/new/button.svg)](https://app.pulumi.com/new) + +# Azure Virtual Data Center (VDC) + +This example deploys an Azure Virtual Data Center (VDC) hub-and-spoke network stack in Azure, complete with ExpressRoute and VPN Gateways, Azure Firewall (with provision for forced tunnelling) guarding a DMZ, and provision for Azure Bastion. Shared services may have their own subnets in the hub, and multiple spokes may be managed with subnets for applications and environments. + +This all works using custom routing to redirect all traffic to and from Azure VNets, as well as all traffic to, within and from the DMZ, through the firewall (which scales out as a service). Traffic between ordinary subnets in the hub and spokes is not redirected through the firewall, and should instead be controlled using Network Security Groups (not yet implemented). Firewall rules are required to allow traffic through (not yet implemented). + +The intention is for matching stacks to be deployed in Azure [paired regions](https://docs.microsoft.com/en-us/azure/best-practices-availability-paired-regions), configured as either Production/Disaster Recovery or High Availability (or both for different applications). Global VNet Peering between the hubs connects the separate stacks into one symmetric network. + +Although the VDC pattern is in widespread use, Azure now offers a managed service intended to replace it, comprising Virtual Hub and SD-WAN components, with a [migration plan](https://docs.microsoft.com/en-us/azure/virtual-wan/migrate-from-hub-spoke-topology) that illustrates the differences between the two patterns. But if you want or need to manage your own network infrastructure, VDC is still relevant. + +This example uses `pulumi.ComponentResource` as described [here](https://www.pulumi.com/docs/intro/concepts/programming-model/#components) which demonstrates how multiple low-level resources can be composed into a higher-level, reusable abstraction. It also demonstrates use of `pulumi.StackReference` as described [here](https://www.pulumi.com/docs/intro/concepts/organizing-stacks-projects/#inter-stack-dependencies) to relate multiple stacks. + +## Prerequisites + +1. [Install Pulumi](https://www.pulumi.com/docs/get-started/install/) +1. [Configure Pulumi for Azure](https://www.pulumi.com/docs/intro/cloud-providers/azure/setup/) +1. [Configure Pulumi for Python](https://www.pulumi.com/docs/intro/languages/python/) + +# Running the Example + +After cloning this repo, `cd` into the `azure-py-virtual-data-center` directory and run the following commands. + +1. (recommended) Create a Python virtualenv, activate it, and install the dependent packages [needed](https://www.pulumi.com/docs/intro/concepts/how-pulumi-works/) for our Pulumi program: + + ``` + $ python3 -m venv venv + $ source venv/bin/activate + $ pip3 install -r requirements.txt + ``` + +1. Create a new stack intended for Production (for example's sake): + + ```bash + $ pulumi stack init prod + ``` + + This will appear within your Pulumi organization under the `azure-py-vdc` project (as specified in `Pulumi.yaml`). + +1. Set the configuration variables for this stack to suit yourself, following guidance in `Pulumi.yaml`. This will create a new `Pulumi.prod.yaml` file (named after the stack) in which to store them: + + Required: + ```bash + $ pulumi config set azure:environment public + $ pulumi config set azure:location australiasoutheast + $ pulumi config set dmz_ar 192.168.100.128/25 + $ pulumi config set fws_ar 192.168.100.0/26 + $ pulumi config set fwz_as 192.168.100.0/24 + $ pulumi config set gws_ar 10.100.0.0/26 + $ pulumi config set hub_as 10.100.0.0/16 + $ pulumi config set hub_stem hub + $ pulumi config set spoke_as 10.101.0.0/16 + $ pulumi config set spoke_stem spoke + ``` + Optional: + ```bash + $ pulumi config set fwm_ar 192.168.100.64/26 + $ pulumi config set hbs_ar 10.100.0.64/27 + $ pulumi config set hub_ar 10.100.1.0/24 + $ pulumi config set sbs_ar 10.101.0.0/27 + $ pulumi config set spoke_ar 10.101.1.0/24 + ``` + +1. Deploy the `prod` stack with the `pulumi up` command. This may take up to an hour to provision all the Azure resources specified, including gateways and firewall: + + ```bash + $ pulumi up + ``` + +1. After a while, your Production stack will be ready. + + ```bash + Updating (prod): + Type Name Status + + pulumi:pulumi:Stack azure-py-vdc-prod creating.. + + ├─ vdc:network:Hub hub creating.. + + │ ├─ azure:network:VirtualNetwork hub-vn- created + + │ ├─ azure:network:PublicIp hub-vpn-gw-pip- created + + │ ├─ azure:network:PublicIp hub-er-gw-pip- created + + │ ├─ azure:network:PublicIp hub-fw-pip- created + + │ ├─ azure:network:Subnet hub-dmz-sn created + + │ ├─ azure:network:Subnet hub-fw-sn created + + │ ├─ azure:network:Subnet hub-fwm-sn created + + │ ├─ azure:network:Subnet hub-ab-sn created + + │ ├─ azure:network:Subnet hub-gw-sn created + + │ ├─ azure:network:VirtualNetworkGateway hub-vpn-gw- created + + │ ├─ azure:network:Firewall hub-fw- created + + │ ├─ azure:network:VirtualNetworkGateway hub-er-gw- created + + │ ├─ azure:network:RouteTable hub-gw-rt- created + + │ ├─ azure:network:RouteTable hub-sn-rt- created + + │ ├─ azure:network:RouteTable hub-dmz-rt- created + + │ ├─ azure:network:Route gw-gw-r- created + + │ ├─ azure:network:SubnetRouteTableAssociation hub-gw-sn-rta created + + │ ├─ azure:network:Route gw-dmz-r- created + + │ ├─ azure:network:Route gw-hub-r- created + + │ ├─ azure:network:Route sn-dg-r- created + + │ ├─ azure:network:Route sn-dmz-r- created + + │ ├─ azure:network:Route sn-gw-r- created + + │ ├─ azure:network:Subnet hub-example-sn- created + + │ ├─ azure:network:SubnetRouteTableAssociation hub-dmz-sn-rta created + + │ ├─ azure:network:Route dmz-dg-r- created + + │ ├─ azure:network:Route dmz-dmz-r- created + + │ ├─ azure:network:Route dmz-hub-r- created + + │ └─ azure:network:SubnetRouteTableAssociation hub-example-sn-rta created + + ├─ azure:core:ResourceGroup prod-vdc-rg- created + + └─ vdc:network:Spoke spoke created + + ├─ azure:network:VirtualNetwork spoke-vn- created + + ├─ azure:network:Route gw-spoke-r- created + + ├─ azure:network:Route sn-spoke-r- created + + ├─ azure:network:Route dmz-spoke-r- created + + ├─ azure:network:Subnet spoke-ab-sn created + + ├─ azure:network:VirtualNetworkPeering spoke-hub-vnp- created + + ├─ azure:network:VirtualNetworkPeering hub-spoke-vnp- created + + ├─ azure:network:RouteTable spoke-sn-rt- created + + ├─ azure:network:Route spoke-dg-r- created + + ├─ azure:network:Route spoke-dmz-r- created + + ├─ azure:network:Subnet spoke-example-sn- created + + ├─ azure:network:Route spoke-hub-r- created + + └─ azure:network:SubnetRouteTableAssociation spoke-example-sn-rta created + + Outputs: + dmz_ar : "192.168.100.128/25" + hub_as : "10.100.0.0/16" + hub_fw_ip : "192.168.100.4" + hub_id : "/subscriptions/subscription/resourceGroups/prod-vdc-rg-6ecb23ab/providers/Microsoft.Network/virtualNetworks/hub-vn-b007c91b" + hub_name : "hub-vn-b007c91b" + spoke_id : "/subscriptions/subscription/resourceGroups/prod-vdc-rg-6ecb23ab/providers/Microsoft.Network/virtualNetworks/spoke-vn-d69aa6c4" + spoke_name : "spoke-vn-d69aa6c4" + + Resources: + + 45 created + + Duration: 48m7s + + Permalink: https://app.pulumi.com/organization/azure-py-vdc/prod/updates/1 + ``` + + Feel free to modify your program, and then run `pulumi up` again. Pulumi automatically detects differences and makes the minimal changes necessary to achieved the desired state. If any changes to resources are made outside of Pulumi, you should first do a `pulumi refresh` so that Pulumi can discover the actual situation, and then `pulumi up` to return to desired state. + + Note that because most resources are [auto-named](https://www.pulumi.com/docs/intro/concepts/programming-model/#autonaming), you see trailing dashes above which will actually be followed by random suffixes that you can see in the Outputs and in Azure. + +1. Create another new stack intended for Disaster Recovery (following the example): + + ```bash + $ pulumi stack init dr + ``` + + This will also appear within your Pulumi organization under the `azure-py-vdc` project (as specified in `Pulumi.yaml`). + +1. Set the configuration variables for this stack which will be stored in a new `Pulumi.dr.yaml` file (change the values below to suit yourself): + + Required: + ```bash + $ pulumi config set azure:environment public + $ pulumi config set azure:location australiaeast + $ pulumi config set dmz_ar 192.168.200.128/25 + $ pulumi config set fws_ar 192.168.200.0/26 + $ pulumi config set fwz_as 192.168.200.0/24 + $ pulumi config set gws_ar 10.200.0.0/26 + $ pulumi config set hub_as 10.200.0.0/16 + $ pulumi config set hub_stem hub + $ pulumi config set spoke_as 10.201.0.0/16 + $ pulumi config set spoke_stem spoke + ``` + Optional: + ```bash + $ pulumi config set fwm_ar 192.168.200.64/26 + $ pulumi config set hbs_ar 10.200.0.64/27 + $ pulumi config set hub_ar 10.200.1.0/24 + $ pulumi config set sbs_ar 10.201.0.0/27 + $ pulumi config set spoke_ar 10.201.1.0/24 + ``` + +1. Deploy the `dr` stack with the `pulumi up` command. Once again, this may take up to an hour to provision all the Azure resources specified, including gateways and firewall: + + ```bash + $ pulumi up + ``` + +1. Once you have both Production and Disaster Recovery stacks (ideally in paired regions), you can connect their hubs using Global (if in different regions) VNet Peering: + + ```bash + $ pulumi stack select prod + $ pulumi config set org + $ pulumi config set peer dr + $ pulumi up + $ pulumi stack select dr + $ pulumi config set org + $ pulumi config set peer prod + $ pulumi up + ``` + Note: it isn't yet [possible](https://github.com/pulumi/pulumi/issues/2800) to discover the Pulumi organization from within the program, which is why you need to set the `org` configuration variable for each stack that needs to peer with another stack. + + If you later destroy a stack, you need to remove the corresponding `peer` variable in the other stack and run `pulumi up`. If you want to tear down the peerings, you should remove the `peer` variables in both stacks and run `pulumi up`: + + ```bash + $ pulumi stack select prod + $ pulumi config rm peer + $ pulumi up + $ pulumi stack select dr + $ pulumi config rm peer + $ pulumi up + ``` + + You need to remove both peerings before you can connect the hubs again. + +1. When you are finished experimenting, you can destroy all of the resources, and the stacks: + + ```bash + $ pulumi stack select prod + $ pulumi destroy + $ pulumi stack rm + $ pulumi stack select dr + $ pulumi destroy + $ pulumi stack rm + ``` \ No newline at end of file diff --git a/azure-py-virtual-data-center/__main__.py b/azure-py-virtual-data-center/__main__.py new file mode 100644 index 000000000..9a0f37683 --- /dev/null +++ b/azure-py-virtual-data-center/__main__.py @@ -0,0 +1,58 @@ +from pulumi import Config, get_stack, ResourceOptions, export +from pulumi.resource import CustomTimeouts +from pulumi_azure import core +from hub import HubProps, Hub +from spoke import SpokeProps, Spoke + +# retrieve the configuration data +config = Config() + +# set default tags to be applied to all taggable resources +stack = get_stack() +default_tags = { + 'environment': stack +} + +# all resources will be created in the Resource Group location +resource_group = core.ResourceGroup( + stack + '-vdc-rg-', + tags = default_tags, +) + +# Hub virtual network with gateway, firewall, DMZ and shared services subnets +hub1 = Hub( + config.require('hub_stem'), + HubProps( + config = config, + resource_group = resource_group, + tags = default_tags, + stack = stack, + ), + opts=ResourceOptions(custom_timeouts=CustomTimeouts(create='1h', update='1h', delete='1h')), +) + +# Spoke virtual network for application environments +spoke1 = Spoke( + config.require('spoke_stem'), + SpokeProps( + config = config, + resource_group = resource_group, + tags = default_tags, + hub = hub1, + ), + opts=ResourceOptions( + depends_on=[hub1.hub_vpn_gw, hub1.hub_er_gw, hub1.hub_fw], + custom_timeouts=CustomTimeouts(create='1h'), + ), +) + +# Exports +export('dmz_ar', config.require('dmz_ar')) +export('hub_as', config.require('hub_as')) +export('hub_fw_ip', hub1.hub_fw_ip) +export('hub_id', hub1.hub_id) +export('hub_name', hub1.hub_name) +export('hub_subnets', hub1.hub_subnets) +export('spoke_id', spoke1.spoke_id) +export('spoke_name', spoke1.spoke_name) +export('spoke_subnets', spoke1.spoke_subnets) diff --git a/azure-py-virtual-data-center/hub.py b/azure-py-virtual-data-center/hub.py new file mode 100644 index 000000000..a79cda1a4 --- /dev/null +++ b/azure-py-virtual-data-center/hub.py @@ -0,0 +1,260 @@ +from pulumi import Config, Output, ComponentResource, ResourceOptions, get_project, StackReference +from pulumi_azure import core, network +import vdc + +class HubProps: + def __init__( + self, + config: Config, + resource_group: core.ResourceGroup, + tags: [str, str], + stack: str, + ): + self.config = config + self.resource_group = resource_group + self.tags = tags + self.stack = stack + +class Hub(ComponentResource): + def __init__(self, name: str, props: HubProps, opts: ResourceOptions=None): + super().__init__('vdc:network:Hub', name, {}, opts) + + # retrieve configuration + dmz_ar = props.config.require('dmz_ar') + fwm_ar = props.config.get('fwm_ar') + fws_ar = props.config.require('fws_ar') + fwz_as = props.config.require('fwz_as') + gws_ar = props.config.require('gws_ar') + hbs_ar = props.config.get('hbs_ar') + hub_ar = props.config.get('hub_ar') + hub_as = props.config.require('hub_as') + + # set vdc defaults + vdc.resource_group_name = props.resource_group.name + vdc.location = props.resource_group.location + vdc.tags = props.tags + vdc.self = self + + # Azure Virtual Network to which spokes will be peered + # separate address spaces to simplify custom routing + hub = vdc.virtual_network(name, [fwz_as, hub_as]) + + # DMZ subnet + hub_dmz_sn = vdc.subnet_special( #ToDo add NSG + stem = f'{name}-dmz', + name = 'DMZ', # name not required but preferred + virtual_network_name = hub.name, + address_prefix = dmz_ar, + ) + + # AzureFirewallSubnet + hub_fw_sn = vdc.subnet_special( + stem = f'{name}-fw', + name = 'AzureFirewallSubnet', # name required + virtual_network_name = hub.name, + address_prefix = fws_ar, + ) + + # GatewaySubnet + hub_gw_sn = vdc.subnet_special( + stem = f'{name}-gw', + name = 'GatewaySubnet', # name required + virtual_network_name = hub.name, + address_prefix = gws_ar, + ) + + # provisioning of Gateways and Firewall depends_on subnets + # to avoid contention in the Azure control plane + + # VPN Gateway + hub_vpn_gw = vdc.vpn_gateway( + stem = name, + subnet_id = hub_gw_sn.id, + depends_on=[hub_dmz_sn, hub_fw_sn, hub_gw_sn], + ) + + # ExpressRoute Gateway + hub_er_gw = vdc.expressroute_gateway( + stem = name, + subnet_id = hub_gw_sn.id, + depends_on=[hub_dmz_sn, hub_fw_sn, hub_gw_sn], + ) + + # Azure Firewall + hub_fw = vdc.firewall( + stem = name, + subnet_id = hub_fw_sn.id, + depends_on=[hub_dmz_sn, hub_fw_sn, hub_gw_sn], + ) + + # provisioning of optional subnets depends_on Gateways and Firewall + # to avoid contention in the Azure control plane + + # AzureBastionSubnet (optional) + if hbs_ar: + hub_ab_sn = vdc.subnet_special( #ToDo add NSG if required + stem = f'{name}-ab', + name = 'AzureBastionSubnet', # name required + virtual_network_name = hub.name, + address_prefix = hbs_ar, + depends_on=[hub_er_gw, hub_fw, hub_vpn_gw], + ) + + # AzureFirewallManagementSubnet (optional) + if fwm_ar: + hub_fwm_sn = vdc.subnet_special( + stem = f'{name}-fwm', + name = 'AzureFirewallManagementSubnet', # name required + virtual_network_name = hub.name, + address_prefix = fwm_ar, + depends_on=[hub_er_gw, hub_fw, hub_vpn_gw], + ) + + # work around https://github.com/pulumi/pulumi/issues/4040 + hub_fw_ip = hub_fw.ip_configurations.apply( + lambda ipc: ipc[0].get('private_ip_address') + ) + + # provisioning of Route Tables depends_on Gateways and Firewall + # to avoid contention in the Azure control plane + + # Route Table only to be associated with the GatewaySubnet + hub_gw_rt = vdc.route_table( + stem = f'{name}-gw', + disable_bgp_route_propagation = False, + depends_on=[hub_er_gw, hub_fw, hub_vpn_gw], + ) + + # associate GatewaySubnet with Route Table + hub_gw_sn_rta = vdc.subnet_route_table( + stem = f'{name}-gw', + route_table_id = hub_gw_rt.id, + subnet_id = hub_gw_sn.id, + ) + + # Route Table only to be associated with DMZ subnet + hub_dmz_rt = vdc.route_table( + stem = f'{name}-dmz', + disable_bgp_route_propagation = True, + depends_on=[hub_er_gw, hub_fw, hub_vpn_gw], + ) + + # associate DMZ subnet with Route Table + hub_dmz_sn_rta = vdc.subnet_route_table( + stem = f'{name}-dmz', + route_table_id = hub_dmz_rt.id, + subnet_id = hub_dmz_sn.id, + ) + + # Route Table only to be associated with ordinary subnets in hub + hub_sn_rt = vdc.route_table( + stem = f'{name}-sn', + disable_bgp_route_propagation = True, + depends_on=[hub_er_gw, hub_fw, hub_vpn_gw], + ) + + # protect intra-GatewaySubnet traffic from being redirected + vdc.route_to_virtual_network( + stem = f'gw-gw', + route_table_name = hub_gw_rt.name, + address_prefix = gws_ar, + ) + + # partially or fully invalidate system routes to redirect traffic + for route in [ + (f'gw-dmz', hub_gw_rt.name, dmz_ar), + (f'gw-hub', hub_gw_rt.name, hub_as), + (f'dmz-dg', hub_dmz_rt.name, '0.0.0.0/0'), + (f'dmz-dmz', hub_dmz_rt.name, dmz_ar), + (f'dmz-hub', hub_dmz_rt.name, hub_as), + (f'sn-dg', hub_sn_rt.name, '0.0.0.0/0'), + (f'sn-dmz', hub_sn_rt.name, dmz_ar), + (f'sn-gw', hub_sn_rt.name, gws_ar), + ]: + vdc.route_to_virtual_appliance( + stem = route[0], + route_table_name = route[1], + address_prefix = route[2], + next_hop_in_ip_address = hub_fw_ip, + ) + + # VNet Peering between stacks using StackReference + peer = props.config.get('peer') + if peer: + org = props.config.require('org') + project = get_project() + peer_stack = StackReference(f'{org}/{project}/{peer}') + peer_hub_id = peer_stack.get_output('hub_id') + peer_fw_ip = peer_stack.get_output('hub_fw_ip') + peer_dmz_ar = peer_stack.get_output('dmz_ar') + peer_hub_as = peer_stack.get_output('hub_as') + + # VNet Peering (Global) in one direction from stack to peer + hub_hub = vdc.vnet_peering( + stem = props.stack, + virtual_network_name = hub.name, + peer = peer, + remote_virtual_network_id = peer_hub_id, + allow_forwarded_traffic = True, + allow_gateway_transit = False, # as both hubs have gateways + ) + + # need to invalidate system routes created by Global VNet Peering + for route in [ + (f'dmz-{peer}-dmz', hub_dmz_rt.name, peer_dmz_ar), + (f'dmz-{peer}-hub', hub_dmz_rt.name, peer_hub_as), + (f'gw-{peer}-dmz', hub_gw_rt.name, peer_dmz_ar), + (f'gw-{peer}-hub', hub_gw_rt.name, peer_hub_as), + (f'sn-{peer}-dmz', hub_sn_rt.name, peer_dmz_ar), + (f'sn-{peer}-hub', hub_sn_rt.name, peer_hub_as), + ]: + vdc.route_to_virtual_appliance( + stem = route[0], + route_table_name = route[1], + address_prefix = route[2], + next_hop_in_ip_address = peer_fw_ip, + ) + + # provisioning of subnets depends_on Route Table (Gateways & Firewall) + # to avoid contention in the Azure control plane + + # only one shared subnet is provisioned as an example, but many can be + if hub_ar: #ToDo replace with loop + hub_example_sn = vdc.subnet( #ToDo add NSG + stem = f'{name}-example', + virtual_network_name = hub.name, + address_prefix = hub_ar, + depends_on=[hub_sn_rt], + ) + + # associate all hub shared services subnets to Route Table + hub_example_sn_rta = vdc.subnet_route_table( + stem = f'{name}-example', + route_table_id = hub_sn_rt.id, + subnet_id = hub_example_sn.id, + ) + + combined_output = Output.all( + hub_dmz_rt.name, + hub_er_gw, + hub_fw, + hub_fw_ip, + hub_gw_rt.name, + hub.id, + hub.name, + hub_sn_rt.name, + hub.subnets, + hub_vpn_gw, + ).apply + + self.hub_dmz_rt_name = hub_dmz_rt.name # used to add routes to spokes + self.hub_er_gw = hub_er_gw # needed prior to VNet Peering from spokes + self.hub_fw = hub_fw # needed prior to VNet Peering from spokes + self.hub_fw_ip = hub_fw_ip # used to construct routes + self.hub_gw_rt_name = hub_gw_rt.name # used to add routes to spokes + self.hub_id = hub.id # exported and used for peering + self.hub_name = hub.name # exported and used for peering + self.hub_sn_rt_name = hub_sn_rt.name # used to add routes to spokes + self.hub_subnets = hub.subnets # exported as informational + self.hub_vpn_gw = hub_vpn_gw # needed prior to VNet Peering from spokes + self.register_outputs({}) diff --git a/azure-py-virtual-data-center/requirements.txt b/azure-py-virtual-data-center/requirements.txt new file mode 100644 index 000000000..e3ec50083 --- /dev/null +++ b/azure-py-virtual-data-center/requirements.txt @@ -0,0 +1,2 @@ +pulumi>=1.0.0,<2.0.0 +pulumi-azure>=2.0.0,<3.0.0 diff --git a/azure-py-virtual-data-center/spoke.py b/azure-py-virtual-data-center/spoke.py new file mode 100644 index 000000000..ff011c95c --- /dev/null +++ b/azure-py-virtual-data-center/spoke.py @@ -0,0 +1,127 @@ +from pulumi import Config, Output, ComponentResource, ResourceOptions +from pulumi_azure import core +from hub import Hub +import vdc + +class SpokeProps: + def __init__( + self, + config: Config, + resource_group: core.ResourceGroup, + tags: [str, str], + hub: Hub, + ): + self.config = config + self.resource_group = resource_group + self.tags = tags + self.hub = hub + +class Spoke(ComponentResource): + def __init__(self, name: str, props: SpokeProps, + opts: ResourceOptions=None): + super().__init__('vdc:network:Spoke', name, {}, opts) + + # retrieve configuration + dmz_ar = props.config.require('dmz_ar') + hub_as = props.config.require('hub_as') + hub_stem = props.config.require('hub_stem') + sbs_ar = props.config.get('sbs_ar') + spoke_ar = props.config.get('spoke_ar') + spoke_as = props.config.require('spoke_as') + + # set vdc defaults + vdc.resource_group_name = props.resource_group.name + vdc.location = props.resource_group.location + vdc.tags = props.tags + vdc.self = self + + # Azure Virtual Network to be peered to the hub + spoke = vdc.virtual_network(name, [spoke_as]) + + # VNet Peering from the hub to spoke + hub_spoke = vdc.vnet_peering( + stem = hub_stem, + virtual_network_name = props.hub.hub_name, + peer = name, + remote_virtual_network_id = spoke.id, + allow_gateway_transit = True, + ) + + # VNet Peering from spoke to the hub + spoke_hub = vdc.vnet_peering( + stem = name, + virtual_network_name = spoke.name, + peer = hub_stem, + remote_virtual_network_id = props.hub.hub_id, + allow_forwarded_traffic = True, + use_remote_gateways = True, + ) + + # provisioning of optional subnet and routes depends_on VNet Peerings + # to avoid contention in the Azure control plane + + # AzureBastionSubnet (optional) + if sbs_ar: + spoke_sbs_sn = vdc.subnet_special( + stem = f'{name}-ab', + name = 'AzureBastionSubnet', + virtual_network_name = spoke.name, + address_prefix = sbs_ar, + depends_on = [hub_spoke, spoke_hub], + ) + + # Route Table only to be associated with ordinary spoke subnets + spoke_sn_rt = vdc.route_table( + stem = f'{name}-sn', + disable_bgp_route_propagation = True, + depends_on = [hub_spoke, spoke_hub], + ) + + # provisioning of subnets depends_on VNet Peerings and Route Table + # to avoid contention in the Azure control plane + + # only one spoke subnet is provisioned as an example, but many can be + if spoke_ar: # replace with a loop + spoke_example_sn = vdc.subnet( + stem = f'{name}-example', + virtual_network_name = spoke.name, + address_prefix = spoke_ar, + depends_on = [spoke_sn_rt], + ) + # associate all ordinary spoke subnets to Route Table + spoke_example_sn_rta = vdc.subnet_route_table( + stem = f'{name}-example', + route_table_id = spoke_sn_rt.id, + subnet_id = spoke_example_sn.id, + ) + + # as VNet Peering may not be specified as next_hop_type, a separate + # address space in the hub from the firewall allows routes from the + # spoke to remain unchanged when subnets are added in the hub + + # it is very important to ensure that there is never a route with an + # address_prefix which covers the AzureFirewallSubnet. + #ToDo check AzureFirewallManagementSubnet requirements + + # partially or fully invalidate system routes to redirect traffic + for route in [ + (f'dmz-{name}', props.hub.hub_dmz_rt_name, spoke_as), + (f'gw-{name}', props.hub.hub_gw_rt_name, spoke_as), + (f'sn-{name}', props.hub.hub_sn_rt_name, spoke_as), + (f'{name}-dg', spoke_sn_rt.name, '0.0.0.0/0'), + (f'{name}-dmz', spoke_sn_rt.name, dmz_ar), + (f'{name}-hub', spoke_sn_rt.name, hub_as), + ]: + vdc.route_to_virtual_appliance( + stem = route[0], + route_table_name = route[1], + address_prefix = route[2], + next_hop_in_ip_address = props.hub.hub_fw_ip, + ) + + combined_output = Output.all(spoke.name, spoke.id, spoke.subnets).apply + + self.spoke_id = spoke.id # exported as informational + self.spoke_name = spoke.name # exported as informational + self.spoke_subnets = spoke.subnets # exported as informational + self.register_outputs({}) diff --git a/azure-py-virtual-data-center/vdc.py b/azure-py-virtual-data-center/vdc.py new file mode 100644 index 000000000..294a0dc1b --- /dev/null +++ b/azure-py-virtual-data-center/vdc.py @@ -0,0 +1,205 @@ +from pulumi import ResourceOptions +from pulumi.resource import CustomTimeouts +from pulumi_azure import network + +# Variables to be injected from the calling class: +# vdc.resource_group_name = props.resource_group.name +# vdc.location = props.resource_group.location +# vdc.tags = props.tags +# vdc.self = self + +def expressroute_gateway(stem, subnet_id, depends_on=[]): + er_gw_pip = network.PublicIp( + f'{stem}-er-gw-pip-', + resource_group_name = resource_group_name, + location = location, + allocation_method = 'Dynamic', + tags = tags, + opts = ResourceOptions(parent=self), + ) + er_gw = network.VirtualNetworkGateway( + f'{stem}-er-gw-', + resource_group_name = resource_group_name, + location = location, + sku = 'Standard', + type = 'ExpressRoute', + vpn_type = 'RouteBased', + ip_configurations = [{ + 'name': f'{stem}-er-gw-ipconf', + 'subnet_id': subnet_id, + 'publicIpAddressId': er_gw_pip.id, + }], + tags = tags, + opts = ResourceOptions(parent=self, depends_on=depends_on), + ) + return er_gw + +def firewall(stem, subnet_id, depends_on=[]): + fw_pip = network.PublicIp( + f'{stem}-fw-pip-', + resource_group_name = resource_group_name, + location = location, + sku = 'Standard', + allocation_method = 'Static', + tags = tags, + opts = ResourceOptions(parent=self), + ) + fw = network.Firewall( + f'{stem}-fw-', + resource_group_name = resource_group_name, + location = location, + ip_configurations = [{ + 'name': f'{stem}-fw-ipconf', + 'subnet_id': subnet_id, + 'publicIpAddressId': fw_pip.id, + }], + tags = tags, + opts = ResourceOptions(parent=self, depends_on=depends_on), + ) + return fw + +def route_table(stem, disable_bgp_route_propagation=None, depends_on=[]): + rt = network.RouteTable( + f'{stem}-rt-', + resource_group_name = resource_group_name, + location = location, + disable_bgp_route_propagation = disable_bgp_route_propagation, + tags = tags, + opts = ResourceOptions(parent=self, depends_on=depends_on), + ) + return rt + +def route_to_virtual_appliance( + stem, + route_table_name, + address_prefix, + next_hop_in_ip_address, + ): + r_va = network.Route( + f'{stem}-r-', + resource_group_name = resource_group_name, + address_prefix = address_prefix, + next_hop_type = 'VirtualAppliance', + next_hop_in_ip_address = next_hop_in_ip_address, + route_table_name = route_table_name, + opts = ResourceOptions(parent=self), + ) + return r_va + +def route_to_virtual_network(stem, route_table_name, address_prefix): + r_vn = network.Route( + f'{stem}-r-', + resource_group_name = resource_group_name, + address_prefix = address_prefix, + next_hop_type = 'VnetLocal', + route_table_name = route_table_name, + opts = ResourceOptions(parent=self), + ) + return r_vn + +def subnet(stem, virtual_network_name, address_prefix, depends_on=[]): + sn = network.Subnet( + f'{stem}-sn-', + resource_group_name = resource_group_name, + address_prefix = address_prefix, + virtual_network_name = virtual_network_name, + opts = ResourceOptions(parent=self, depends_on=depends_on), + ) + return sn + +def subnet_route_table(stem, route_table_id, subnet_id): + rta = network.SubnetRouteTableAssociation( + f'{stem}-sn-rta', + route_table_id = route_table_id, + subnet_id = subnet_id, + opts = ResourceOptions(parent=self), + ) + return rta + +def subnet_special( + stem, + name, + virtual_network_name, + address_prefix, + depends_on=[], + ): + sn = network.Subnet( + f'{stem}-sn', + name = name, + resource_group_name = resource_group_name, + address_prefix = address_prefix, + virtual_network_name = virtual_network_name, + opts = ResourceOptions( + parent=self, + delete_before_replace=True, + depends_on=depends_on, + ), + ) + return sn + +def virtual_network(stem, address_spaces): + vn = network.VirtualNetwork( + f'{stem}-vn-', + resource_group_name = resource_group_name, + location = location, + address_spaces = address_spaces, + tags = tags, + opts = ResourceOptions(parent=self), + ) + return vn + +def vnet_peering( + stem, + virtual_network_name, + peer, + remote_virtual_network_id, + allow_forwarded_traffic=None, + allow_gateway_transit=None, + use_remote_gateways=None, + ): + vnp = network.VirtualNetworkPeering( + f'{stem}-{peer}-vnp-', + resource_group_name = resource_group_name, + virtual_network_name = virtual_network_name, + remote_virtual_network_id = remote_virtual_network_id, + allow_forwarded_traffic = allow_forwarded_traffic, + allow_gateway_transit = allow_gateway_transit, + use_remote_gateways = use_remote_gateways, + allow_virtual_network_access = True, + opts = ResourceOptions(parent=self), + ) + return vnp + +def vpn_gateway(stem, subnet_id, depends_on=[]): + vpn_gw_pip = network.PublicIp( + f'{stem}-vpn-gw-pip-', + resource_group_name = resource_group_name, + location = location, + allocation_method = 'Dynamic', + tags = tags, + opts = ResourceOptions(parent=self), + ) + vpn_gw = network.VirtualNetworkGateway( + f'{stem}-vpn-gw-', + resource_group_name = resource_group_name, + location = location, + sku = 'VpnGw1', + type = 'Vpn', + vpn_type = 'RouteBased', + ip_configurations = [{ + 'name': f'{stem}-vpn-gw-ipconf', + 'subnet_id': subnet_id, + 'publicIpAddressId': vpn_gw_pip.id, + }], + tags = tags, + opts = ResourceOptions( + parent=self, + depends_on=depends_on, + custom_timeouts=CustomTimeouts(create='1h', update='1h', delete='1h'), + ), + ) + return vpn_gw + +if __name__ == "__main__": + import sys + vdc(int(sys.argv[1]))