From fb90500adcfc2c53634bd2a4dbbc5cb8d2324cdf Mon Sep 17 00:00:00 2001 From: Simone Ruffilli Date: Mon, 28 Feb 2022 12:27:27 +0100 Subject: [PATCH] FAST: add 02-networking-peering stage. (#561) * Networking with peering - initial commit * README and tests * Linter fixes * Linter fixes * Linter fixes * Update README.md * split out vpn/peering in separate files so the rest is identical * fix dns for vpn and peering * update tfdoc to support multiple outputs/variables files * add peering variables * update tfdoc for modules * make it easier to spot errored docs * fix doc * yapf * fix permadiff in firewall module source ranges * FAST: Networking: DNS Fixes * FAST: ability to toggle on-prem vpn creation * FAST: fix outputs Co-authored-by: Ludovico Magnocavallo --- .../packer-image-builder/packer/README.md | 13 +- examples/factories/project-factory/main.tf | 2 +- .../data/firewall-rules/dev/rules.yaml | 4 +- fast/stages/02-networking-nva/dns-dev.tf | 4 +- fast/stages/02-networking-nva/dns-landing.tf | 28 - fast/stages/02-networking-nva/dns-prod.tf | 4 +- fast/stages/02-networking-nva/outputs.tf | 4 +- fast/stages/02-networking-nva/vpn-onprem.tf | 5 +- fast/stages/02-networking-peering/.gitignore | 1 + fast/stages/02-networking-peering/IAM.md | 16 + fast/stages/02-networking-peering/README.md | 317 +++ .../02-networking-peering/data/cidrs.yaml | 15 + .../data/dashboards/firewall_insights.json | 68 + .../data/dashboards/vpn.json | 248 +++ .../data/firewall-rules/dev/rules.yaml | 27 + .../data/firewall-rules/landing/rules.yaml | 15 + .../data/hierarchical-policy-rules.yaml | 49 + .../subnets/dev/dev-dataplatform-ew1.yaml | 8 + .../data/subnets/dev/dev-default-ew1.yaml | 5 + .../subnets/landing/landing-default-ew1.yaml | 5 + .../data/subnets/prod/prod-default-ew1.yaml | 5 + fast/stages/02-networking-peering/diagram.png | Bin 0 -> 86938 bytes fast/stages/02-networking-peering/diagram.svg | 1930 +++++++++++++++++ fast/stages/02-networking-peering/dns-dev.tf | 53 + .../02-networking-peering/dns-landing.tf | 71 + fast/stages/02-networking-peering/dns-prod.tf | 53 + fast/stages/02-networking-peering/landing.tf | 99 + fast/stages/02-networking-peering/main.tf | 58 + .../02-networking-peering/monitoring.tf | 32 + fast/stages/02-networking-peering/outputs.tf | 91 + fast/stages/02-networking-peering/peerings.tf | 43 + .../stages/02-networking-peering/spoke-dev.tf | 114 + .../02-networking-peering/spoke-prod.tf | 114 + .../02-networking-peering/test-resources.tf | 100 + .../variables-peerings.tf | 36 + .../stages/02-networking-peering/variables.tf | 220 ++ .../02-networking-peering/vpn-onprem.tf | 62 + fast/stages/02-networking-vpn/README.md | 10 +- .../data/firewall-rules/dev/rules.yaml | 4 +- fast/stages/02-networking-vpn/dns-dev.tf | 4 +- fast/stages/02-networking-vpn/dns-landing.tf | 22 - fast/stages/02-networking-vpn/dns-prod.tf | 4 +- fast/stages/02-networking-vpn/main.tf | 13 - fast/stages/02-networking-vpn/outputs.tf | 4 +- .../stages/02-networking-vpn/variables-vpn.tf | 88 + fast/stages/02-networking-vpn/variables.tf | 67 +- fast/stages/02-networking-vpn/vpn-onprem.tf | 6 +- .../stages/02-networking-vpn/vpn-spoke-dev.tf | 38 +- .../02-networking-vpn/vpn-spoke-prod.tf | 58 +- .../cloud-config-container/coredns/README.md | 3 + .../cloud-config-container/mysql/README.md | 3 + .../cloud-config-container/nginx/README.md | 3 + .../cloud-config-container/onprem/README.md | 3 + .../cloud-config-container/squid/README.md | 3 + modules/net-vpc-firewall/main.tf | 24 +- .../stages/s02_networking_peering/__init__.py | 13 + .../s02_networking_peering/fixture/main.tf | 44 + .../s02_networking_peering/test_plan.py | 20 + .../stages/s02_networking_vpn/fixture/main.tf | 16 - tools/check_documentation.py | 8 +- tools/tfdoc.py | 90 +- 61 files changed, 4212 insertions(+), 255 deletions(-) create mode 100644 fast/stages/02-networking-peering/.gitignore create mode 100644 fast/stages/02-networking-peering/IAM.md create mode 100644 fast/stages/02-networking-peering/README.md create mode 100644 fast/stages/02-networking-peering/data/cidrs.yaml create mode 100644 fast/stages/02-networking-peering/data/dashboards/firewall_insights.json create mode 100644 fast/stages/02-networking-peering/data/dashboards/vpn.json create mode 100644 fast/stages/02-networking-peering/data/firewall-rules/dev/rules.yaml create mode 100644 fast/stages/02-networking-peering/data/firewall-rules/landing/rules.yaml create mode 100644 fast/stages/02-networking-peering/data/hierarchical-policy-rules.yaml create mode 100644 fast/stages/02-networking-peering/data/subnets/dev/dev-dataplatform-ew1.yaml create mode 100644 fast/stages/02-networking-peering/data/subnets/dev/dev-default-ew1.yaml create mode 100644 fast/stages/02-networking-peering/data/subnets/landing/landing-default-ew1.yaml create mode 100644 fast/stages/02-networking-peering/data/subnets/prod/prod-default-ew1.yaml create mode 100644 fast/stages/02-networking-peering/diagram.png create mode 100644 fast/stages/02-networking-peering/diagram.svg create mode 100644 fast/stages/02-networking-peering/dns-dev.tf create mode 100644 fast/stages/02-networking-peering/dns-landing.tf create mode 100644 fast/stages/02-networking-peering/dns-prod.tf create mode 100644 fast/stages/02-networking-peering/landing.tf create mode 100644 fast/stages/02-networking-peering/main.tf create mode 100644 fast/stages/02-networking-peering/monitoring.tf create mode 100644 fast/stages/02-networking-peering/outputs.tf create mode 100644 fast/stages/02-networking-peering/peerings.tf create mode 100644 fast/stages/02-networking-peering/spoke-dev.tf create mode 100644 fast/stages/02-networking-peering/spoke-prod.tf create mode 100644 fast/stages/02-networking-peering/test-resources.tf create mode 100644 fast/stages/02-networking-peering/variables-peerings.tf create mode 100644 fast/stages/02-networking-peering/variables.tf create mode 100644 fast/stages/02-networking-peering/vpn-onprem.tf create mode 100644 fast/stages/02-networking-vpn/variables-vpn.tf create mode 100644 tests/fast/stages/s02_networking_peering/__init__.py create mode 100644 tests/fast/stages/s02_networking_peering/fixture/main.tf create mode 100644 tests/fast/stages/s02_networking_peering/test_plan.py diff --git a/examples/cloud-operations/packer-image-builder/packer/README.md b/examples/cloud-operations/packer-image-builder/packer/README.md index 68649734b..18bcaece0 100644 --- a/examples/cloud-operations/packer-image-builder/packer/README.md +++ b/examples/cloud-operations/packer-image-builder/packer/README.md @@ -7,17 +7,6 @@ The example uses following GCP features: * [service account impersonation](https://cloud.google.com/iam/docs/impersonating-service-accounts) * [Identity-Aware Proxy](https://cloud.google.com/iap/docs/using-tcp-forwarding) tunnel - -## Variables -| name | description | type | required | default | -|---|---|:---: |:---:|:---:| -| builder_sa | Image builder's service account email. | string | ✓ | | -| compute_sa | Temporary's VM service account email. | string | ✓ | | -| compute_subnetwork | Name of a VPC subnetwork for temporary VM instance. | string | ✓ | | -| compute_zone | Compute Engine zone to run temporary VM instance. | string | ✓ | | -| project_id | Project id that references existing GCP project. | string | ✓ | | -| *use_iap* | Indicates to use IAP tunnel for communication with temporary VM instance. | bool | | true | - - \ No newline at end of file + diff --git a/examples/factories/project-factory/main.tf b/examples/factories/project-factory/main.tf index 5f8356fc6..4efdaeac0 100644 --- a/examples/factories/project-factory/main.tf +++ b/examples/factories/project-factory/main.tf @@ -139,7 +139,7 @@ module "billing-alert" { module "dns" { source = "../../../modules/dns" for_each = toset(var.dns_zones) - project_id = module.project.project_id + project_id = coalesce(local.vpc.host_project, module.project.project_id) type = "private" name = each.value domain = "${each.value}.${var.defaults.environment_dns_zone}" diff --git a/fast/stages/02-networking-nva/data/firewall-rules/dev/rules.yaml b/fast/stages/02-networking-nva/data/firewall-rules/dev/rules.yaml index f2d614a59..d4df8cdc3 100644 --- a/fast/stages/02-networking-nva/data/firewall-rules/dev/rules.yaml +++ b/fast/stages/02-networking-nva/data/firewall-rules/dev/rules.yaml @@ -5,7 +5,7 @@ ingress-allow-composer-nodes: direction: INGRESS action: allow sources: [] - ranges: [] + ranges: ["0.0.0.0/0"] targets: - composer-worker use_service_accounts: false @@ -18,7 +18,7 @@ ingress-allow-dataflow-load: direction: INGRESS action: allow sources: [] - ranges: [] + ranges: ["0.0.0.0/0"] targets: - dataflow use_service_accounts: false diff --git a/fast/stages/02-networking-nva/dns-dev.tf b/fast/stages/02-networking-nva/dns-dev.tf index 08de34cce..08c99486f 100644 --- a/fast/stages/02-networking-nva/dns-dev.tf +++ b/fast/stages/02-networking-nva/dns-dev.tf @@ -20,11 +20,11 @@ module "dev-dns-private-zone" { source = "../../../modules/dns" - project_id = module.landing-project.project_id + project_id = module.dev-spoke-project.project_id type = "private" name = "dev-gcp-example-com" domain = "dev.gcp.example.com." - client_networks = [module.dev-spoke-vpc.self_link] + client_networks = [module.landing-trusted-vpc.self_link, module.landing-untrusted-vpc.self_link] recordsets = { "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } } diff --git a/fast/stages/02-networking-nva/dns-landing.tf b/fast/stages/02-networking-nva/dns-landing.tf index f13ef9960..f177dcda2 100644 --- a/fast/stages/02-networking-nva/dns-landing.tf +++ b/fast/stages/02-networking-nva/dns-landing.tf @@ -59,34 +59,6 @@ module "gcp-example-dns-private-zone" { } } -# GCP-specific DNS zones peered to the environment spoke that holds the config - -module "prod-gcp-example-dns-peering" { - source = "../../../modules/dns" - project_id = module.landing-project.project_id - type = "peering" - name = "prod-root-dns-peering" - domain = "prod.gcp.example.com." - client_networks = [ - module.landing-untrusted-vpc.self_link, - module.landing-trusted-vpc.self_link - ] - peer_network = module.prod-spoke-vpc.self_link -} - -module "dev-gcp-example-dns-peering" { - source = "../../../modules/dns" - project_id = module.landing-project.project_id - type = "peering" - name = "dev-root-dns-peering" - domain = "dev.gcp.example.com." - client_networks = [ - module.landing-untrusted-vpc.self_link, - module.landing-trusted-vpc.self_link - ] - peer_network = module.dev-spoke-vpc.self_link -} - # Google API zone to trigger Private Access module "googleapis-private-zone" { diff --git a/fast/stages/02-networking-nva/dns-prod.tf b/fast/stages/02-networking-nva/dns-prod.tf index d92157e82..335f1508e 100644 --- a/fast/stages/02-networking-nva/dns-prod.tf +++ b/fast/stages/02-networking-nva/dns-prod.tf @@ -20,11 +20,11 @@ module "prod-dns-private-zone" { source = "../../../modules/dns" - project_id = module.landing-project.project_id + project_id = module.prod-spoke-project.project_id type = "private" name = "prod-gcp-example-com" domain = "prod.gcp.example.com." - client_networks = [module.prod-spoke-vpc.self_link] + client_networks = [module.landing-trusted-vpc.self_link, module.landing-untrusted-vpc.self_link] recordsets = { "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } } diff --git a/fast/stages/02-networking-nva/outputs.tf b/fast/stages/02-networking-nva/outputs.tf index 0b93bf4b2..3cd187b0f 100644 --- a/fast/stages/02-networking-nva/outputs.tf +++ b/fast/stages/02-networking-nva/outputs.tf @@ -68,11 +68,11 @@ output "vpn_gateway_endpoints" { description = "External IP Addresses for the GCP VPN gateways." value = { onprem-ew1 = { - for v in module.landing-to-onprem-ew1-vpn.gateway.vpn_interfaces : + for v in module.landing-to-onprem-ew1-vpn[0].gateway.vpn_interfaces : v.id => v.ip_address } onprem-ew4 = { - for v in module.landing-to-onprem-ew4-vpn.gateway.vpn_interfaces : + for v in module.landing-to-onprem-ew4-vpn[0].gateway.vpn_interfaces : v.id => v.ip_address } } diff --git a/fast/stages/02-networking-nva/vpn-onprem.tf b/fast/stages/02-networking-nva/vpn-onprem.tf index d28a66ee4..c860c0998 100644 --- a/fast/stages/02-networking-nva/vpn-onprem.tf +++ b/fast/stages/02-networking-nva/vpn-onprem.tf @@ -17,7 +17,8 @@ # tfdoc:file:description VPN between landing and onprem. locals { - bgp_peer_options_onprem = { + enable_onprem_vpn = var.vpn_onprem_configs != null + bgp_peer_options_onprem = local.enable_onprem_vpn == false ? null : { for k, v in var.vpn_onprem_configs : k => v.adv == null ? null : { advertise_groups = [] @@ -32,6 +33,7 @@ locals { } module "landing-to-onprem-ew1-vpn" { + count = local.enable_onprem_vpn ? 1 : 0 source = "../../../modules/net-vpn-ha" project_id = module.landing-project.project_id network = module.landing-trusted-vpc.self_link @@ -60,6 +62,7 @@ module "landing-to-onprem-ew1-vpn" { } module "landing-to-onprem-ew4-vpn" { + count = local.enable_onprem_vpn ? 1 : 0 source = "../../../modules/net-vpn-ha" project_id = module.landing-project.project_id network = module.landing-trusted-vpc.self_link diff --git a/fast/stages/02-networking-peering/.gitignore b/fast/stages/02-networking-peering/.gitignore new file mode 100644 index 000000000..19af5b744 --- /dev/null +++ b/fast/stages/02-networking-peering/.gitignore @@ -0,0 +1 @@ +ludo-* diff --git a/fast/stages/02-networking-peering/IAM.md b/fast/stages/02-networking-peering/IAM.md new file mode 100644 index 000000000..f5c690672 --- /dev/null +++ b/fast/stages/02-networking-peering/IAM.md @@ -0,0 +1,16 @@ +# IAM bindings reference + +Legend: + additive, conditional. + +## Project dev-net-spoke-0 + +| members | roles | +|---|---| +|dev-resman-pf-0
serviceAccount|[roles/resourcemanager.projectIamAdmin](https://cloud.google.com/iam/docs/understanding-roles#resourcemanager.projectIamAdmin)
[roles/dns.admin](https://cloud.google.com/iam/docs/understanding-roles#dns.admin) | +|prod-resman-pf-0
serviceAccount|organizations/[org_id #0]/roles/serviceProjectNetworkAdmin | + +## Project prod-net-spoke-0 + +| members | roles | +|---|---| +|prod-resman-pf-0
serviceAccount|[roles/resourcemanager.projectIamAdmin](https://cloud.google.com/iam/docs/understanding-roles#resourcemanager.projectIamAdmin)
organizations/[org_id #0]/roles/serviceProjectNetworkAdmin
[roles/dns.admin](https://cloud.google.com/iam/docs/understanding-roles#dns.admin) | diff --git a/fast/stages/02-networking-peering/README.md b/fast/stages/02-networking-peering/README.md new file mode 100644 index 000000000..2b3e98800 --- /dev/null +++ b/fast/stages/02-networking-peering/README.md @@ -0,0 +1,317 @@ +# Networking + +This stage sets up the shared network infrastructure for the whole organization. It adopts the common “hub and spoke” reference design, which is well suited to multiple scenarios, and offers several advantages versus other designs: + +- the “hub” VPC centralizes external connectivity to on-prem or other cloud environments, and is ready to host cross-environment services like CI/CD, code repositories, and monitoring probes +- the “spoke” VPCs allow partitioning workloads (e.g. by environment like in this setup), while still retaining controlled access to central connectivity and services +- Shared VPC in both hub and spokes splits management of network resources in specific (host) projects, while still allowing them to be consumed from workload (service) projects +- the design also lends itself to easy DNS centralization, both from on-prem to cloud and from cloud to on-prem + +Connectivity between hub and spokes is established here via [VPC Peering](https://cloud.google.com/vpc/docs/vpc-peering), which offers a complete isolation between environments, and no choke-points in the data plane. Different ways of implementing connectivity, and their respective pros and cons, are discussed below. + +The following diagram illustrates the high-level design, and should be used as a reference for the following sections. The final number of subnets, and their IP addressing design will of course depend on customer-specific requirements, and can be easily changed via variables or external data files without having to edit the actual code. + +

+ Networking diagram +

+ +## Design overview and choices + +### VPC design + +The hub/landing VPC hosts external connectivity and shared services for spoke VPCs, which are connected to it via VPC peering. Spokes are used here to partition environments, which is a fairly common pattern: + +- one spoke VPC for the production environment +- one spoke VPC for the development environment + +Each VPC is created into its own project, and each project is configured as a Shared VPC host, so that network-related resources and access configurations via IAM are kept separate for each VPC. + +The design easily lends itself to implementing additional environments, or adopting a different logical mapping for spokes (e.g. one spoke for each company entity, etc.). Adding spokes is a trivial operation, does not increase the design complexity, and is explained in operational terms in the following sections. + +In multi-organization scenarios, where production and non-production resources use different Cloud Identity and GCP organizations, the hub/landing VPC is usually part of the production organization, and establishes connections with production spokes in its same organization, and non-production spokes in a different organization. + +### External connectivity + +External connectivity to on-prem is implemented here via VPN HA (two tunnels per region), as this is the minimum common denominator often used directly, or as a stop-gap solution to validate routing and transfer data, while waiting for [interconnects](https://cloud.google.com/network-connectivity/docs/interconnect) to be provisioned. + +Connectivity to additional on-prem sites or other cloud providers should be implemented in a similar fashion, via VPN tunnels or interconnects in the landing VPC sharing the same regional router. + +### Internal connectivity + +Each environment has full line of sight with the Landing VPC, and hence with any networks interconnected with it (e.g. your onprem environment). Environments cannot communicate with each other (prod to dev and viceversa). If this is a requirement, and according to your specific needs and constraints, solutions based on full-mesh peerings, VPNs or NVA should be added to this design. + +As mentioned initially, there are of course other ways to implement internal connectivity other than VPC peering. These can be easily retrofitted with minimal code changes, but introduce additional considerations for service interoperability, quotas and management. + +This is a summary of the main options: + +- [VPN HA](https://cloud.google.com/network-connectivity/docs/vpn/concepts/topologies) (implemented on [02-networking-vpn](../02-networking-vpn/)) + - Pros: simple compatibility with GCP services that leverage peering internally, better control on routes, avoids peering groups shared quotas and limits + - Cons: additional cost, marginal increase in latency, requires multiple tunnels for full bandwidth +- [VPC Peering](https://cloud.google.com/vpc/docs/vpc-peering) (implemented here) + - Pros: no additional costs, full bandwidth with no configurations, no extra latency, total environment isolation + - Cons: no transitivity (e.g. to GKE masters, Cloud SQL, etc.), no selective exchange of routes, several quotas and limits shared between VPCs in a peering group +- [Multi-NIC appliances](https://cloud.google.com/architecture/best-practices-vpc-design#multi-nic) (implemented on [02-networking-nva](../02-networking-nva/)) + - Pros: additional security features (e.g. IPS), potentially better integration with on-prem systems by using the same vendor + - Cons: complex HA/failover setup, limited by VM bandwidth and scale, additional costs for VMs and licenses, out of band management of a critical cloud component + +### IP ranges, subnetting, routing + +Minimizing the number of routes (and subnets) in use on the cloud environment is an important consideration, as it simplifies management and avoids hitting [Cloud Router](https://cloud.google.com/network-connectivity/docs/router/quotas) and [VPC](https://cloud.google.com/vpc/docs/quota) quotas and limits. For this reason, we recommend careful planning of the IP space used in your cloud environment, to be able to use large IP CIDR blocks in routes whenever possible. + +This stage uses a dedicated /16 block (which should of course be sized to your needs) for each region in each VPC, and subnets created in each VPC derive their ranges from the relevant block. + +Spoke VPCs also define and reserve two "special" CIDR ranges dedicated to [PSA (Private Service Access)](https://cloud.google.com/vpc/docs/private-services-access) and [Internal HTTPs Load Balancers (L7ILB)](https://cloud.google.com/load-balancing/docs/l7-internal). + +Routes in GCP are either automatically created for VPC subnets, manually created via static routes, or dynamically programmed by [Cloud Routers](https://cloud.google.com/network-connectivity/docs/router#docs) via BGP sessions, which can be configured to advertise VPC ranges, and/or custom ranges via custom advertisements. + +In this setup: + +- routes between multiple subnets within the same VPC are automatically programmed by GCP +- each spoke exchanges routes with the hub/landing through VPC peering +- spokes don't exchange routes, directly or indirectly +- on-premises is connected to the landing VPC and dynamically exchanges BGP routes with GCP using VPN HA + +### Internet egress + +The path of least resistance for Internet egress is using Cloud NAT, and that is what's implemented in this setup, with a NAT gateway configured for each VPC. + +Several other scenarios are possible of course, with varying degrees of complexity: + +- a forward proxy, with optional URL filters +- a default route to on-prem to leverage existing egress infrastructure +- a full-fledged perimeter firewall to control egress and implement additional security features like IPS + +Future pluggable modules will allow to easily experiment, or deploy the above scenarios. + +### VPC and Hierarchical Firewall + +The GCP Firewall is a stateful, distributed feature that allows the creation of L4 policies, either via VPC-level rules or more recently via hierarchical policies applied on the resource hierarchy (organization, folders). + +The current setup adopts both firewall types, and uses hierarchical rules on the Networking folder for common ingress rules (egress is open by default), e.g. from health check or IAP forwarders ranges, and VPC rules for the environment or workload-level ingress. + +Rules and policies are defined in simple YAML files, described below. + +### DNS + +DNS often goes hand in hand with networking, especially on GCP where Cloud DNS zones and policies are associated at the VPC level. This setup implements both DNS flows: + +- on-prem to cloud via private zones for cloud-managed domains, and an [inbound policy](https://cloud.google.com/dns/docs/server-policies-overview#dns-server-policy-in) used as forwarding target or via delegation (requires some extra configuration) from on-prem DNS resolvers +- cloud to on-prem via forwarding zones for the on-prem managed domains + +DNS configuration is further centralized by leveraging peering zones, so that + +- the hub/landing Cloud DNS hosts configurations for on-prem forwarding and Google API domains, with the spokes consuming them via DNS peering zones +- the spokes Cloud DNS host configurations for the environment-specific domains, with the hub/landing VPC acting as consumer via DNS peering + +To complete the configuration, the 35.199.192.0/19 range should be routed on the VPN tunnels from on-prem, and the following names configured for DNS forwarding to cloud: + +- `private.googleapis.com` +- `restricted.googleapis.com` +- `gcp.example.com` (used as a placeholder) + +From cloud, the `example.com` domain (used as a placeholder) is forwarded to on-prem. + +This configuration is battle-tested, and flexible enough to lend itself to simple modifications without subverting its design, for example by forwarding and peering root zones to bypass Cloud DNS external resolution. + +## How to run this stage + +This stage is meant to be executed after the [resman](../01-resman) stage has run, as it leverages the automation service account and bucket created there, and additional resources configured in the [bootstrap](../00-bootstrap) stage. + +It's of course possible to run this stage in isolation, but that's outside the scope of this document, and you would need to refer to the code for the previous stages for the environmental requirements. + +Before running this stage, you need to make sure you have the correct credentials and permissions, and localize variables by assigning values that match your configuration. + +### Providers configuration + +The default way of making sure you have the right permissions, is to use the identity of the service account pre-created for this stage during the [resource management](../01-resman) stage, and that you are a member of the group that can impersonate it via provider-level configuration (`gcp-devops` or `organization-admins`). + +To simplify setup, the previous stage pre-configures a valid providers file in its output, and optionally writes it to a local file if the `outputs_location` variable is set to a valid path. + +If you have set a valid value for `outputs_location` in the bootstrap stage, simply link the relevant `providers.tf` file from this stage's folder in the path you specified: + +```bash +# `outputs_location` is set to `~/fast-config` +ln -s ~/fast-config/providers/02-networking-providers.tf . +``` + +If you have not configured `outputs_location` in bootstrap, you can derive the providers file from that stage's outputs: + +```bash +cd ../01-resman +terraform output -json providers | jq -r '.["02-networking"]' \ + > ../02-networking/providers.tf +``` + +### Variable configuration + +There are two broad sets of variables you will need to fill in: + +- variables shared by other stages (org id, billing account id, etc.), or derived from a resource managed by a different stage (folder id, automation project id, etc.) +- variables specific to resources managed by this stage + +To avoid the tedious job of filling in the first group of variables with values derived from other stages' outputs, the same mechanism used above for the provider configuration can be used to leverage pre-configured `.tfvars` files. + +If you have set a valid value for `outputs_location` in the bootstrap and in the resman stage, simply link the relevant `terraform-*.auto.tfvars.json` files from this stage's folder in the path you specified, where the `*` above is set to the name of the stage that produced it. For this stage, a single `.tfvars` file is available: + +```bash +# `outputs_location` is set to `~/fast-config` +ln -s ../../configs/example/02-networking/terraform-bootstrap.auto.tfvars.json +ln -s ../../configs/example/02-networking/terraform-resman.auto.tfvars.json +# also copy the tfvars file used for the bootstrap stage +cp ../00-bootstrap/terraform.tfvars . +``` + +A second set of variables is specific to this stage, they are all optional so if you need to customize them, add them to the file copied from bootstrap. + +Please refer to the [Variables](#variables) table below for a map of the variable origins, and to the sections below on how to adapt this stage to your networking configuration. + +### VPCs + +VPCs are defined in separate files, one for `landing` and one for each of `prod` and `dev`. +Each file contains the same resources, described in the following paragraphs. + +The **project** ([`project`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/project)) contains the VPC, and enables the required APIs and sets itself as a "[host project](https://cloud.google.com/vpc/docs/shared-vpc)". + +The **VPC** ([`net-vpc`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpc)) manages the DNS inbound policy (for Landing), explicit routes for `{private,restricted}.googleapis.com`, and its **subnets**. Subnets are created leveraging a "resource factory" paradigm, where the configuration is separated from the module that implements it, and stored in a well-structured file. To add a new subnet, simply create a new file in the `data_folder` directory defined in the module, following the examples found in the [Fabric `net-vpc` documentation](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpc#subnet-factory). Sample subnets are shipped in [data/subnets](./data/subnets), and can be easily customised to fit your needs. + +Subnets for [L7 ILBs](https://cloud.google.com/load-balancing/docs/l7-internal/proxy-only-subnets) are handled differently, and defined in variable `l7ilb_subnets`, while ranges for [PSA](https://cloud.google.com/vpc/docs/configure-private-services-access#allocating-range) are configured by variable `psa_ranges` - such variables are consumed by spoke VPCs. + +**Cloud NAT** ([`net-cloudnat`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-cloudnat)) manages the networking infrastructure required to enable internet egress. + +### VPNs + +Connectivity to on-prem is implemented with VPN HA ([`net-vpn`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpn)) and defined in [`vpn-onprem.tf`](./vpn-onprem.tf). The file provisionally implements a single logical connection between onprem and landing at `europe-west1`, and the relevant parameters for its configuration are found in variable `vpn_onprem_configs`. + +### Routing and BGP + +Each VPC network ([`net-vpc`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpc)) manages a separate routing table, which can define static routes (e.g. to private.googleapis.com) and receives dynamic routes from BGP sessions established with neighbor networks (i.e. landing receives routes from onprem and any other interconnected network). Spokes receive dynamic routes programmed on the Landing VPC from the VPC peering. + +Static routes are defined in `vpc-*.tf` files, in the `routes` section of each `net-vpc` module. + +### Firewall + +**VPC firewall rules** ([`net-vpc-firewall`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpc-firewall)) are defined per-vpc on each `vpc-*.tf` file and leverage a resource factory to massively create rules. +To add a new firewall rule, create a new file or edit an existing one in the `data_folder` directory defined in the module `net-vpc-firewall`, following the examples of the "[Rules factory](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/net-vpc-firewall#rules-factory)" section of the module documentation. Sample firewall rules are shipped in [data/firewall-rules/landing](./data/firewall-rules/landing) and can be easily customised. + +**Hierarchical firewall policies** ([`folder`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/folder)) are defined in `main.tf`, and managed through a policy factory implemented by the `folder` module, which applies the defined hierarchical to the `Networking` folder, which contains all the core networking infrastructure. Policies are defined in the `rules_file` file - to define a new one simply use the instructions found on "[Firewall policy factory](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/organization#firewall-policy-factory)". Sample hierarchical firewall policies are shipped in [data/hierarchical-policy-rules.yaml](./data/hierarchical-policy-rules.yaml) and can be easily customised. + +### DNS architecture + +The DNS ([`dns`](https://github.com/terraform-google-modules/cloud-foundation-fabric/tree/master/modules/dns)) infrastructure is defined in the respective `vpc-xxx.tf` files. + +Cloud DNS manages onprem forwarding, the main GCP zone (in this example `gcp.example.com`) and is peered to environment-specific zones (i.e. `dev.gcp.example.com` and `prod.gcp.example.com`). + +#### Cloud environment + +Per the section above Landing acts as the source of truth for DNS within the Cloud environment. Resources defined in the spoke VPCs consume the Landing DNS infrastructure through DNS peering (e.g. `prod-landing-root-dns-peering`). +Spokes can optionally define private zones (e.g. `prod-dns-private-zone`) - granting visibility to the Landing VPC ensures that the whole cloud environment can query such zones. + +#### Cloud to on-prem + +Leveraging the forwarding zones defined on Landing (e.g. `onprem-example-dns-forwarding` and `reverse-10-dns-forwarding`), the cloud environment can resolve `in-addr.arpa.` and `onprem.example.com.` using the on-premises DNS infrastructure. Onprem resolvers IPs are set in variable `dns.onprem`. + +DNS queries sent to the on-premises infrastructure come from the `35.199.192.0/19` source range, which is only accessible from within a VPC or networks connected to one. + +#### On-prem to cloud + +The [Inbound DNS Policy](https://cloud.google.com/dns/docs/server-policies-overview#dns-server-policy-in) defined in module `landing-vpc` ([`landing.tf`](./landing.tf)) automatically reserves the first available IP address on each created subnet (typically the third one in a CIDR) to expose the Cloud DNS service so that it can be consumed from outside of GCP. + +### Private Google Access + +[Private Google Access](https://cloud.google.com/vpc/docs/private-google-access) (or PGA) enables VMs and on-prem systems to consume Google APIs from within the Google network, and is already fully configured on this environment. + +For PGA to work: + +- Private Google Access should be enabled on the subnet. \ +Subnets created by the `net-vpc` module are PGA-enabled by default. + +- 199.36.153.4/30 (`restricted.googleapis.com`) and 199.36.153.8/30 (`private.googleapis.com`) should be routed from on-prem to VPC, and from there to the `default-internet-gateway`. \ +Per variable `vpn_onprem_configs` such ranges are advertised to onprem - furthermore every VPC (e.g. see `landing-vpc` in [`landing.tf`](./landing.tf)) has explicit routes set in case the `0.0.0.0/0` route is changed. + +- A private DNS zone for `googleapis.com` should be created and configured per [this article](https://cloud.google.com/vpc/docs/configure-private-google-access-hybrid#config-domain), as implemented in module `googleapis-private-zone` in [`dns.tf`](./dns.tf) + +### Preliminar activities + +Before running `terraform apply` on this stage, make sure to adapt all of `variables.tf` to your needs, to update all reference to regions (e.g. `europe-west1` or `ew1`) in the whole directory to match your preferences. + +If you're not using FAST, you'll also need to create a `providers.tf` file to configure the GCS backend and the service account to use to run the deployment. + +You're now ready to run `terraform init` and `apply`. + +### Post-deployment activities + +- On-prem routers should be configured to advertise all relevant CIDRs to the GCP environments. To avoid hitting GCP quotas, we recomment aggregating routes as much as possible. +- On-prem routers should accept BGP sessions from their cloud peers. +- On-prem DNS servers should have forward zones for GCP-managed ones. + +## Customizations + +### Adding an environment + +To create a new environment (e.g. `staging`), a few changes are required. + +Create a `spoke-staging.tf` file by copying `spoke-prod.tf` file, +and adapt the new file by replacing the value "prod" with the value "staging". +Running `diff spoke-dev.tf spoke-prod.tf` can help to see how environment files differ. + +The new VPC requires a set of dedicated CIDRs, one per region, added to variable `custom_adv` (for example as `spoke_staging_ew1` and `spoke_staging_ew4`). +>`custom_adv` is a map that "resolves" CIDR names to actual addresses, and will be used later to configure routing. +> +Variables managing L7 Interal Load Balancers (`l7ilb_subnets`) and Private Service Access (`psa_ranges`) should also be adapted, and subnets and firewall rules for the new spoke should be added as described above. + +DNS configurations are centralised in the `dns.tf` file. Spokes delegate DNS resolution to Landing through DNS peering, and optionally define a private zone (e.g. `staging.gcp.example.com`) which the landing peers to. To configure DNS for a new environment, copy all the `prod-*` modules in the `dns.tf` file to `staging-*`, and update their content accordingly. Don't forget to add a peering zone from Landing to the newly created environment private zone. + + + + +## Files + +| name | description | modules | resources | +|---|---|---|---| +| [dns-dev.tf](./dns-dev.tf) | Development spoke DNS zones and peerings setup. | dns | | +| [dns-landing.tf](./dns-landing.tf) | Landing DNS zones and peerings setup. | dns | | +| [dns-prod.tf](./dns-prod.tf) | Production spoke DNS zones and peerings setup. | dns | | +| [landing.tf](./landing.tf) | Landing VPC and related resources. | net-cloudnat · net-vpc · net-vpc-firewall · project | | +| [main.tf](./main.tf) | Networking folder and hierarchical policy. | folder | | +| [monitoring.tf](./monitoring.tf) | Network monitoring dashboards. | | google_monitoring_dashboard | +| [outputs.tf](./outputs.tf) | Module outputs. | | local_file | +| [peerings.tf](./peerings.tf) | None | net-vpc-peering | | +| [spoke-dev.tf](./spoke-dev.tf) | Dev spoke VPC and related resources. | net-cloudnat · net-vpc · net-vpc-firewall · project | google_project_iam_binding | +| [spoke-prod.tf](./spoke-prod.tf) | Production spoke VPC and related resources. | net-cloudnat · net-vpc · net-vpc-firewall · project | google_project_iam_binding | +| [test-resources.tf](./test-resources.tf) | temporary instances for testing | compute-vm | | +| [variables-peerings.tf](./variables-peerings.tf) | Peering related variables. | | | +| [variables.tf](./variables.tf) | Module variables. | | | +| [vpn-onprem.tf](./vpn-onprem.tf) | VPN between landing and onprem. | net-vpn-ha | | + +## Variables + +| name | description | type | required | default | producer | +|---|---|:---:|:---:|:---:|:---:| +| [billing_account](variables.tf#L17) | Billing account id and organization id ('nnnnnnnn' or null). | object({…}) | ✓ | | 00-bootstrap | +| [folder_ids](variables.tf#L66) | Folders to be used for the networking resources in folders/nnnnnnnnnnn format. If null, folder will be created. | object({…}) | ✓ | | 01-resman | +| [organization](variables.tf#L94) | Organization details. | object({…}) | ✓ | | 00-bootstrap | +| [prefix](variables.tf#L110) | Prefix used for resources that need unique names. Use 9 characters or less. | string | ✓ | | 00-bootstrap | +| [custom_adv](variables.tf#L26) | Custom advertisement definitions in name => range format. | map(string) | | {…} | | +| [custom_roles](variables.tf#L43) | Custom roles defined at the org level, in key => id format. | object({…}) | | null | 00-bootstrap | +| [data_dir](variables.tf#L52) | Relative path for the folder storing configuration data for network resources. | string | | "data" | | +| [dns](variables.tf#L58) | Onprem DNS resolvers. | map(list(string)) | | {…} | | +| [l7ilb_subnets](variables.tf#L76) | Subnets used for L7 ILBs. | map(list(object({…}))) | | {…} | | +| [outputs_location](variables.tf#L104) | Path where providers and tfvars files for the following stages are written. Leave empty to disable. | string | | null | | +| [peering_configs](variables-peerings.tf#L19) | Peering configurations. | map(object({…})) | | {…} | | +| [psa_ranges](variables.tf#L121) | IP ranges used for Private Service Access (e.g. CloudSQL). | map(map(string)) | | {…} | | +| [router_onprem_configs](variables.tf#L136) | Configurations for routers used for onprem connectivity. | map(object({…})) | | {…} | | +| [service_accounts](variables.tf#L154) | Automation service accounts in name => email format. | object({…}) | | null | 01-resman | +| [vpn_onprem_configs](variables.tf#L166) | VPN gateway configuration for onprem interconnection. | map(object({…})) | | {…} | | + +## Outputs + +| name | description | sensitive | consumers | +|---|---|:---:|---| +| [cloud_dns_inbound_policy](outputs.tf#L57) | IP Addresses for Cloud DNS inbound policy. | | | +| [host_project_ids](outputs.tf#L62) | Network project ids. | | | +| [host_project_numbers](outputs.tf#L67) | Network project numbers. | | | +| [shared_vpc_self_links](outputs.tf#L72) | Shared VPC host projects. | | | +| [tfvars](outputs.tf#L87) | Terraform variables file for the following stages. | ✓ | | +| [vpn_gateway_endpoints](outputs.tf#L77) | External IP Addresses for the GCP VPN gateways. | | | + + diff --git a/fast/stages/02-networking-peering/data/cidrs.yaml b/fast/stages/02-networking-peering/data/cidrs.yaml new file mode 100644 index 000000000..b6c25e21a --- /dev/null +++ b/fast/stages/02-networking-peering/data/cidrs.yaml @@ -0,0 +1,15 @@ +# skip boilerplate check + +healthchecks: + - 35.191.0.0/16 + - 130.211.0.0/22 + - 209.85.152.0/22 + - 209.85.204.0/22 + +rfc1918: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + +onprem_probes: + - 10.255.255.254/32 diff --git a/fast/stages/02-networking-peering/data/dashboards/firewall_insights.json b/fast/stages/02-networking-peering/data/dashboards/firewall_insights.json new file mode 100644 index 000000000..e829091cf --- /dev/null +++ b/fast/stages/02-networking-peering/data/dashboards/firewall_insights.json @@ -0,0 +1,68 @@ +{ + "displayName": "Firewall Insights Monitoring", + "gridLayout": { + "columns": "2", + "widgets": [ + { + "title": "Subnet Firewall Hit Counts", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"firewallinsights.googleapis.com/subnet/firewall_hit_count\" resource.type=\"gce_subnetwork\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "VM Firewall Hit Counts", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"firewallinsights.googleapis.com/vm/firewall_hit_count\" resource.type=\"gce_instance\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + } + ] + } +} \ No newline at end of file diff --git a/fast/stages/02-networking-peering/data/dashboards/vpn.json b/fast/stages/02-networking-peering/data/dashboards/vpn.json new file mode 100644 index 000000000..4396cc00b --- /dev/null +++ b/fast/stages/02-networking-peering/data/dashboards/vpn.json @@ -0,0 +1,248 @@ +{ + "displayName": "VPN Monitoring", + "gridLayout": { + "columns": "2", + "widgets": [ + { + "title": "Number of connections", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"vpn.googleapis.com/gateway/connections\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Tunnel established", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_MEAN" + }, + "filter": "metric.type=\"vpn.googleapis.com/tunnel_established\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Cloud VPN Gateway - Received bytes", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/received_bytes_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "By" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Cloud VPN Gateway - Sent bytes", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/sent_bytes_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "By" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Cloud VPN Gateway - Received packets", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/received_packets_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "{packets}" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Cloud VPN Gateway - Sent packets", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/sent_packets_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "{packets}" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Incoming packets dropped", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/dropped_received_packets_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + }, + { + "title": "Outgoing packets dropped", + "xyChart": { + "chartOptions": { + "mode": "COLOR" + }, + "dataSets": [ + { + "minAlignmentPeriod": "60s", + "plotType": "LINE", + "targetAxis": "Y1", + "timeSeriesQuery": { + "timeSeriesFilter": { + "aggregation": { + "perSeriesAligner": "ALIGN_RATE" + }, + "filter": "metric.type=\"vpn.googleapis.com/network/dropped_sent_packets_count\" resource.type=\"vpn_gateway\"", + "secondaryAggregation": {} + }, + "unitOverride": "1" + } + } + ], + "timeshiftDuration": "0s", + "yAxis": { + "label": "y1Axis", + "scale": "LINEAR" + } + } + } + ] + } +} \ No newline at end of file diff --git a/fast/stages/02-networking-peering/data/firewall-rules/dev/rules.yaml b/fast/stages/02-networking-peering/data/firewall-rules/dev/rules.yaml new file mode 100644 index 000000000..d4df8cdc3 --- /dev/null +++ b/fast/stages/02-networking-peering/data/firewall-rules/dev/rules.yaml @@ -0,0 +1,27 @@ +# skip boilerplate check + +ingress-allow-composer-nodes: + description: "Allow traffic to Composer nodes." + direction: INGRESS + action: allow + sources: [] + ranges: ["0.0.0.0/0"] + targets: + - composer-worker + use_service_accounts: false + rules: + - protocol: tcp + ports: [80, 443, 3306, 3307] + +ingress-allow-dataflow-load: + description: "Allow traffic to Dataflow nodes." + direction: INGRESS + action: allow + sources: [] + ranges: ["0.0.0.0/0"] + targets: + - dataflow + use_service_accounts: false + rules: + - protocol: tcp + ports: [12345, 12346] diff --git a/fast/stages/02-networking-peering/data/firewall-rules/landing/rules.yaml b/fast/stages/02-networking-peering/data/firewall-rules/landing/rules.yaml new file mode 100644 index 000000000..e72b7c9c7 --- /dev/null +++ b/fast/stages/02-networking-peering/data/firewall-rules/landing/rules.yaml @@ -0,0 +1,15 @@ +# skip boilerplate check + +allow-onprem-probes-example: + description: "Allow traffic from onprem probes" + direction: INGRESS + action: allow + sources: [] + ranges: + - $onprem_probes + targets: [] + use_service_accounts: false + rules: + - protocol: tcp + ports: + - 12345 diff --git a/fast/stages/02-networking-peering/data/hierarchical-policy-rules.yaml b/fast/stages/02-networking-peering/data/hierarchical-policy-rules.yaml new file mode 100644 index 000000000..0172a3091 --- /dev/null +++ b/fast/stages/02-networking-peering/data/hierarchical-policy-rules.yaml @@ -0,0 +1,49 @@ +# skip boilerplate check + +allow-admins: + description: Access from the admin subnet to all subnets + direction: INGRESS + action: allow + priority: 1000 + ranges: + - $rfc1918 + ports: + all: [] + target_resources: null + enable_logging: false + +allow-healthchecks: + description: Enable HTTP and HTTPS healthchecks + direction: INGRESS + action: allow + priority: 1001 + ranges: + - $healthchecks + ports: + tcp: ["80", "443"] + target_resources: null + enable_logging: false + +allow-ssh-from-iap: + description: Enable SSH from IAP + direction: INGRESS + action: allow + priority: 1002 + ranges: + - 35.235.240.0/20 + ports: + tcp: ["22"] + target_resources: null + enable_logging: false + +allow-icmp: + description: Enable ICMP + direction: INGRESS + action: allow + priority: 1003 + ranges: + - 0.0.0.0/0 + ports: + icmp: [] + target_resources: null + enable_logging: false diff --git a/fast/stages/02-networking-peering/data/subnets/dev/dev-dataplatform-ew1.yaml b/fast/stages/02-networking-peering/data/subnets/dev/dev-dataplatform-ew1.yaml new file mode 100644 index 000000000..92994826d --- /dev/null +++ b/fast/stages/02-networking-peering/data/subnets/dev/dev-dataplatform-ew1.yaml @@ -0,0 +1,8 @@ +# skip boilerplate check + +region: europe-west1 +description: Default subnet for dev Data Platform +ip_cidr_range: 10.128.48.0/24 +secondary_ip_range: + pods: 100.128.48.0/20 + services: 100.255.48.0/24 diff --git a/fast/stages/02-networking-peering/data/subnets/dev/dev-default-ew1.yaml b/fast/stages/02-networking-peering/data/subnets/dev/dev-default-ew1.yaml new file mode 100644 index 000000000..8b066ba70 --- /dev/null +++ b/fast/stages/02-networking-peering/data/subnets/dev/dev-default-ew1.yaml @@ -0,0 +1,5 @@ +# skip boilerplate check + +region: europe-west1 +ip_cidr_range: 10.128.32.0/24 +description: Default subnet for dev diff --git a/fast/stages/02-networking-peering/data/subnets/landing/landing-default-ew1.yaml b/fast/stages/02-networking-peering/data/subnets/landing/landing-default-ew1.yaml new file mode 100644 index 000000000..5af68db6d --- /dev/null +++ b/fast/stages/02-networking-peering/data/subnets/landing/landing-default-ew1.yaml @@ -0,0 +1,5 @@ +# skip boilerplate check + +region: europe-west1 +ip_cidr_range: 10.128.0.0/24 +description: Default subnet for landing diff --git a/fast/stages/02-networking-peering/data/subnets/prod/prod-default-ew1.yaml b/fast/stages/02-networking-peering/data/subnets/prod/prod-default-ew1.yaml new file mode 100644 index 000000000..0052eff95 --- /dev/null +++ b/fast/stages/02-networking-peering/data/subnets/prod/prod-default-ew1.yaml @@ -0,0 +1,5 @@ +# skip boilerplate check + +region: europe-west1 +ip_cidr_range: 10.128.64.0/24 +description: Default subnet for prod diff --git a/fast/stages/02-networking-peering/diagram.png b/fast/stages/02-networking-peering/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..3171c5a5c1096d16a979687a0a0c075eed2aa0ef GIT binary patch literal 86938 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fast/stages/02-networking-peering/dns-dev.tf b/fast/stages/02-networking-peering/dns-dev.tf new file mode 100644 index 000000000..aad50afc3 --- /dev/null +++ b/fast/stages/02-networking-peering/dns-dev.tf @@ -0,0 +1,53 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Development spoke DNS zones and peerings setup. + +# GCP-specific environment zone + +module "dev-dns-private-zone" { + source = "../../../modules/dns" + project_id = module.dev-spoke-project.project_id + type = "private" + name = "dev-gcp-example-com" + domain = "dev.gcp.example.com." + client_networks = [module.landing-vpc.self_link] + recordsets = { + "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } + } +} + +# root zone peering to landing to centralize configuration; remove if unneeded + +module "dev-landing-root-dns-peering" { + source = "../../../modules/dns" + project_id = module.dev-spoke-project.project_id + type = "peering" + name = "dev-root-dns-peering" + domain = "." + client_networks = [module.dev-spoke-vpc.self_link] + peer_network = module.landing-vpc.self_link +} + +module "dev-reverse-10-dns-peering" { + source = "../../../modules/dns" + project_id = module.dev-spoke-project.project_id + type = "peering" + name = "dev-reverse-10-dns-peering" + domain = "10.in-addr.arpa." + client_networks = [module.dev-spoke-vpc.self_link] + peer_network = module.landing-vpc.self_link +} diff --git a/fast/stages/02-networking-peering/dns-landing.tf b/fast/stages/02-networking-peering/dns-landing.tf new file mode 100644 index 000000000..b1d766ab2 --- /dev/null +++ b/fast/stages/02-networking-peering/dns-landing.tf @@ -0,0 +1,71 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Landing DNS zones and peerings setup. + +# forwarding to on-prem DNS resolvers + +module "onprem-example-dns-forwarding" { + source = "../../../modules/dns" + project_id = module.landing-project.project_id + type = "forwarding" + name = "example-com" + domain = "onprem.example.com." + client_networks = [module.landing-vpc.self_link] + forwarders = { for ip in var.dns.onprem : ip => null } +} + +module "reverse-10-dns-forwarding" { + source = "../../../modules/dns" + project_id = module.landing-project.project_id + type = "forwarding" + name = "root-reverse-10" + domain = "10.in-addr.arpa." + client_networks = [module.landing-vpc.self_link] + forwarders = { for ip in var.dns.onprem : ip => null } +} + +module "gcp-example-dns-private-zone" { + source = "../../../modules/dns" + project_id = module.landing-project.project_id + type = "private" + name = "gcp-example-com" + domain = "gcp.example.com." + client_networks = [module.landing-vpc.self_link] + recordsets = { + "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } + } +} + +# Google API zone to trigger Private Access + +module "googleapis-private-zone" { + source = "../../../modules/dns" + project_id = module.landing-project.project_id + type = "private" + name = "googleapis-com" + domain = "googleapis.com." + client_networks = [module.landing-vpc.self_link] + recordsets = { + "A private" = { type = "A", ttl = 300, records = [ + "199.36.153.8", "199.36.153.9", "199.36.153.10", "199.36.153.11" + ] } + "A restricted" = { type = "A", ttl = 300, records = [ + "199.36.153.4", "199.36.153.5", "199.36.153.6", "199.36.153.7" + ] } + "CNAME *" = { type = "CNAME", ttl = 300, records = ["private.googleapis.com."] } + } +} diff --git a/fast/stages/02-networking-peering/dns-prod.tf b/fast/stages/02-networking-peering/dns-prod.tf new file mode 100644 index 000000000..a4a916b46 --- /dev/null +++ b/fast/stages/02-networking-peering/dns-prod.tf @@ -0,0 +1,53 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Production spoke DNS zones and peerings setup. + +# GCP-specific environment zone + +module "prod-dns-private-zone" { + source = "../../../modules/dns" + project_id = module.prod-spoke-project.project_id + type = "private" + name = "prod-gcp-example-com" + domain = "prod.gcp.example.com." + client_networks = [module.landing-vpc.self_link] + recordsets = { + "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } + } +} + +# root zone peering to landing to centralize configuration; remove if unneeded + +module "prod-landing-root-dns-peering" { + source = "../../../modules/dns" + project_id = module.prod-spoke-project.project_id + type = "peering" + name = "prod-root-dns-peering" + domain = "." + client_networks = [module.prod-spoke-vpc.self_link] + peer_network = module.landing-vpc.self_link +} + +module "prod-reverse-10-dns-peering" { + source = "../../../modules/dns" + project_id = module.prod-spoke-project.project_id + type = "peering" + name = "prod-reverse-10-dns-peering" + domain = "10.in-addr.arpa." + client_networks = [module.prod-spoke-vpc.self_link] + peer_network = module.landing-vpc.self_link +} diff --git a/fast/stages/02-networking-peering/landing.tf b/fast/stages/02-networking-peering/landing.tf new file mode 100644 index 000000000..fae959570 --- /dev/null +++ b/fast/stages/02-networking-peering/landing.tf @@ -0,0 +1,99 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Landing VPC and related resources. + +module "landing-project" { + source = "../../../modules/project" + billing_account = var.billing_account.id + name = "prod-net-landing-0" + parent = var.folder_ids.networking-prod + prefix = var.prefix + service_config = { + disable_on_destroy = false + disable_dependent_services = false + } + services = [ + "compute.googleapis.com", + "dns.googleapis.com", + "iap.googleapis.com", + "networkmanagement.googleapis.com", + "stackdriver.googleapis.com" + ] + shared_vpc_host_config = { + enabled = true + service_projects = [] + } + iam = { + "roles/dns.admin" = [local.service_accounts.project-factory-prod] + (local.custom_roles.service_project_network_admin) = [ + local.service_accounts.project-factory-prod + ] + } +} + +module "landing-vpc" { + source = "../../../modules/net-vpc" + project_id = module.landing-project.project_id + name = "prod-landing-0" + mtu = 1500 + dns_policy = { + inbound = true + logging = false + outbound = null + } + # set explicit routes for googleapis in case the default route is deleted + routes = { + private-googleapis = { + dest_range = "199.36.153.8/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + restricted-googleapis = { + dest_range = "199.36.153.4/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + } + data_folder = "${var.data_dir}/subnets/landing" +} + +module "landing-firewall" { + source = "../../../modules/net-vpc-firewall" + project_id = module.landing-project.project_id + network = module.landing-vpc.name + admin_ranges = [] + http_source_ranges = [] + https_source_ranges = [] + ssh_source_ranges = [] + data_folder = "${var.data_dir}/firewall-rules/landing" + cidr_template_file = "${var.data_dir}/cidrs.yaml" +} + +module "landing-nat-ew1" { + source = "../../../modules/net-cloudnat" + project_id = module.landing-project.project_id + region = "europe-west1" + name = "ew1" + router_create = true + router_name = "prod-nat-ew1" + router_network = module.landing-vpc.name + router_asn = 4200001024 +} diff --git a/fast/stages/02-networking-peering/main.tf b/fast/stages/02-networking-peering/main.tf new file mode 100644 index 000000000..5df6d604e --- /dev/null +++ b/fast/stages/02-networking-peering/main.tf @@ -0,0 +1,58 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Networking folder and hierarchical policy. + +locals { + custom_roles = coalesce(var.custom_roles, {}) + l7ilb_subnets = { + for env, v in var.l7ilb_subnets : env => [ + for s in v : merge(s, { + active = true + name = "${env}-l7ilb-${s.region}" + })] + } + region_trigram = { + europe-west1 = "ew1" + europe-west3 = "ew3" + } + stage3_sas_delegated_grants = [ + "roles/composer.sharedVpcAgent", + "roles/compute.networkUser", + "roles/container.hostServiceAgentUser", + "roles/vpcaccess.user", + ] + service_accounts = { + for k, v in coalesce(var.service_accounts, {}) : k => "serviceAccount:${v}" + } +} + +module "folder" { + source = "../../../modules/folder" + parent = "organizations/${var.organization.id}" + name = "Networking" + folder_create = var.folder_ids.networking == null + id = var.folder_ids.networking + firewall_policy_factory = { + cidr_file = "${var.data_dir}/cidrs.yaml" + policy_name = null + rules_file = "${var.data_dir}/hierarchical-policy-rules.yaml" + } + firewall_policy_association = { + factory-policy = "factory" + } +} + diff --git a/fast/stages/02-networking-peering/monitoring.tf b/fast/stages/02-networking-peering/monitoring.tf new file mode 100644 index 000000000..7b8b70c51 --- /dev/null +++ b/fast/stages/02-networking-peering/monitoring.tf @@ -0,0 +1,32 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Network monitoring dashboards. + +locals { + dashboard_path = "${var.data_dir}/dashboards" + dashboard_files = fileset(local.dashboard_path, "*.json") + dashboards = { + for filename in local.dashboard_files : + filename => "${local.dashboard_path}/${filename}" + } +} + +resource "google_monitoring_dashboard" "dashboard" { + for_each = local.dashboards + project = module.landing-project.project_id + dashboard_json = file(each.value) +} diff --git a/fast/stages/02-networking-peering/outputs.tf b/fast/stages/02-networking-peering/outputs.tf new file mode 100644 index 000000000..3fe18d657 --- /dev/null +++ b/fast/stages/02-networking-peering/outputs.tf @@ -0,0 +1,91 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +locals { + host_project_ids = { + dev-spoke-0 = module.dev-spoke-project.project_id + prod-landing = module.landing-project.project_id + prod-spoke-0 = module.prod-spoke-project.project_id + } + host_project_numbers = { + dev-spoke-0 = module.dev-spoke-project.number + prod-landing = module.landing-project.number + prod-spoke-0 = module.prod-spoke-project.number + } + subnet_self_links = { + prod-landing = module.landing-vpc.subnet_self_links + dev-spoke-0 = module.dev-spoke-vpc.subnet_self_links + prod-spoke-0 = module.prod-spoke-vpc.subnet_self_links + } + tfvars = { + host_project_ids = local.host_project_ids + host_project_numbers = local.host_project_numbers + subnet_self_links = local.subnet_self_links + vpc_self_links = local.vpc_self_links + } + vpc_self_links = { + prod-landing = module.landing-vpc.self_link + dev-spoke-0 = module.dev-spoke-vpc.self_link + prod-spoke-0 = module.prod-spoke-vpc.self_link + } +} + +# optionally generate tfvars file for subsequent stages + +resource "local_file" "tfvars" { + for_each = var.outputs_location == null ? {} : { 1 = 1 } + file_permission = "0644" + filename = "${pathexpand(var.outputs_location)}/tfvars/02-networking.auto.tfvars.json" + content = jsonencode(local.tfvars) +} + +# outputs + +output "cloud_dns_inbound_policy" { + description = "IP Addresses for Cloud DNS inbound policy." + value = [for s in module.landing-vpc.subnets : cidrhost(s.ip_cidr_range, 2)] +} + +output "host_project_ids" { + description = "Network project ids." + value = local.host_project_ids +} + +output "host_project_numbers" { + description = "Network project numbers." + value = local.host_project_numbers +} + +output "shared_vpc_self_links" { + description = "Shared VPC host projects." + value = local.vpc_self_links +} + +output "vpn_gateway_endpoints" { + description = "External IP Addresses for the GCP VPN gateways." + value = local.enable_onprem_vpn == false ? null : { + onprem-ew1 = { + for v in module.landing-to-onprem-ew1-vpn[0].gateway.vpn_interfaces : + v.id => v.ip_address + } + } +} + +output "tfvars" { + description = "Terraform variables file for the following stages." + sensitive = true + value = local.tfvars +} diff --git a/fast/stages/02-networking-peering/peerings.tf b/fast/stages/02-networking-peering/peerings.tf new file mode 100644 index 000000000..13dbf63d1 --- /dev/null +++ b/fast/stages/02-networking-peering/peerings.tf @@ -0,0 +1,43 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "peering-dev" { + source = "../../../modules/net-vpc-peering" + prefix = "dev-peering-0" + local_network = module.dev-spoke-vpc.self_link + peer_network = module.landing-vpc.self_link + export_local_custom_routes = try( + var.peering_configs.dev.export_local_custom_routes, null + ) + export_peer_custom_routes = try( + var.peering_configs.dev.export_peer_custom_routes, null + ) +} + +module "peering-prod" { + source = "../../../modules/net-vpc-peering" + prefix = "prod-peering-0" + local_network = module.prod-spoke-vpc.self_link + peer_network = module.landing-vpc.self_link + depends_on = [module.peering-dev] + export_local_custom_routes = try( + var.peering_configs.prod.export_local_custom_routes, null + ) + export_peer_custom_routes = try( + var.peering_configs.prod.export_peer_custom_routes, null + ) +} + diff --git a/fast/stages/02-networking-peering/spoke-dev.tf b/fast/stages/02-networking-peering/spoke-dev.tf new file mode 100644 index 000000000..d62949afc --- /dev/null +++ b/fast/stages/02-networking-peering/spoke-dev.tf @@ -0,0 +1,114 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Dev spoke VPC and related resources. + +module "dev-spoke-project" { + source = "../../../modules/project" + billing_account = var.billing_account.id + name = "dev-net-spoke-0" + parent = var.folder_ids.networking-dev + prefix = var.prefix + service_config = { + disable_on_destroy = false + disable_dependent_services = false + } + services = [ + "container.googleapis.com", + "compute.googleapis.com", + "dns.googleapis.com", + "iap.googleapis.com", + "networkmanagement.googleapis.com", + "servicenetworking.googleapis.com", + ] + shared_vpc_host_config = { + enabled = true + service_projects = [] + } + metric_scopes = [module.landing-project.project_id] + iam = { + "roles/dns.admin" = [local.service_accounts.project-factory-dev] + } +} + +module "dev-spoke-vpc" { + source = "../../../modules/net-vpc" + project_id = module.dev-spoke-project.project_id + name = "dev-spoke-0" + mtu = 1500 + data_folder = "${var.data_dir}/subnets/dev" + psa_ranges = var.psa_ranges.dev + subnets_l7ilb = local.l7ilb_subnets.dev + # set explicit routes for googleapis in case the default route is deleted + routes = { + private-googleapis = { + dest_range = "199.36.153.8/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + restricted-googleapis = { + dest_range = "199.36.153.4/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + } +} + +module "dev-spoke-firewall" { + source = "../../../modules/net-vpc-firewall" + project_id = module.dev-spoke-project.project_id + network = module.dev-spoke-vpc.name + admin_ranges = [] + http_source_ranges = [] + https_source_ranges = [] + ssh_source_ranges = [] + data_folder = "${var.data_dir}/firewall-rules/dev" + cidr_template_file = "${var.data_dir}/cidrs.yaml" +} + +module "dev-spoke-cloudnat" { + for_each = toset(values(module.dev-spoke-vpc.subnet_regions)) + source = "../../../modules/net-cloudnat" + project_id = module.dev-spoke-project.project_id + region = each.value + name = "dev-nat-${local.region_trigram[each.value]}" + router_create = true + router_network = module.dev-spoke-vpc.name + router_asn = 4200001024 + logging_filter = "ERRORS_ONLY" +} + +# Create delegated grants for stage3 service accounts +resource "google_project_iam_binding" "dev_spoke_project_iam_delegated" { + project = module.dev-spoke-project.project_id + role = "roles/resourcemanager.projectIamAdmin" + members = [ + local.service_accounts.data-platform-dev, + local.service_accounts.project-factory-dev, + ] + condition { + title = "dev_stage3_sa_delegated_grants" + description = "Development host project delegated grants." + expression = format( + "api.getAttribute('iam.googleapis.com/modifiedGrantsByRole', []).hasOnly([%s])", + join(",", formatlist("'%s'", local.stage3_sas_delegated_grants)) + ) + } +} diff --git a/fast/stages/02-networking-peering/spoke-prod.tf b/fast/stages/02-networking-peering/spoke-prod.tf new file mode 100644 index 000000000..001bab75d --- /dev/null +++ b/fast/stages/02-networking-peering/spoke-prod.tf @@ -0,0 +1,114 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description Production spoke VPC and related resources. + +module "prod-spoke-project" { + source = "../../../modules/project" + billing_account = var.billing_account.id + name = "prod-net-spoke-0" + parent = var.folder_ids.networking-prod + prefix = var.prefix + service_config = { + disable_on_destroy = false + disable_dependent_services = false + } + services = [ + "container.googleapis.com", + "compute.googleapis.com", + "dns.googleapis.com", + "iap.googleapis.com", + "networkmanagement.googleapis.com", + "servicenetworking.googleapis.com", + ] + shared_vpc_host_config = { + enabled = true + service_projects = [] + } + metric_scopes = [module.landing-project.project_id] + iam = { + "roles/dns.admin" = [local.service_accounts.project-factory-prod] + } +} + +module "prod-spoke-vpc" { + source = "../../../modules/net-vpc" + project_id = module.prod-spoke-project.project_id + name = "prod-spoke-0" + mtu = 1500 + data_folder = "${var.data_dir}/subnets/prod" + psa_ranges = var.psa_ranges.prod + subnets_l7ilb = local.l7ilb_subnets.prod + # set explicit routes for googleapis in case the default route is deleted + routes = { + private-googleapis = { + dest_range = "199.36.153.8/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + restricted-googleapis = { + dest_range = "199.36.153.4/30" + priority = 1000 + tags = [] + next_hop_type = "gateway" + next_hop = "default-internet-gateway" + } + } +} + +module "prod-spoke-firewall" { + source = "../../../modules/net-vpc-firewall" + project_id = module.prod-spoke-project.project_id + network = module.prod-spoke-vpc.name + admin_ranges = [] + http_source_ranges = [] + https_source_ranges = [] + ssh_source_ranges = [] + data_folder = "${var.data_dir}/firewall-rules/prod" + cidr_template_file = "${var.data_dir}/cidrs.yaml" +} + +module "prod-spoke-cloudnat" { + for_each = toset(values(module.prod-spoke-vpc.subnet_regions)) + source = "../../../modules/net-cloudnat" + project_id = module.prod-spoke-project.project_id + region = each.value + name = "prod-nat-${local.region_trigram[each.value]}" + router_create = true + router_network = module.prod-spoke-vpc.name + router_asn = 4200001024 + logging_filter = "ERRORS_ONLY" +} + +# Create delegated grants for stage3 service accounts +resource "google_project_iam_binding" "prod_spoke_project_iam_delegated" { + project = module.prod-spoke-project.project_id + role = "roles/resourcemanager.projectIamAdmin" + members = [ + local.service_accounts.data-platform-prod, + local.service_accounts.project-factory-prod, + ] + condition { + title = "prod_stage3_sa_delegated_grants" + description = "Production host project delegated grants." + expression = format( + "api.getAttribute('iam.googleapis.com/modifiedGrantsByRole', []).hasOnly([%s])", + join(",", formatlist("'%s'", local.stage3_sas_delegated_grants)) + ) + } +} diff --git a/fast/stages/02-networking-peering/test-resources.tf b/fast/stages/02-networking-peering/test-resources.tf new file mode 100644 index 000000000..8139e7551 --- /dev/null +++ b/fast/stages/02-networking-peering/test-resources.tf @@ -0,0 +1,100 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +# tfdoc:file:description temporary instances for testing + +# module "test-vm-landing-0" { +# source = "../../../modules/compute-vm" +# project_id = module.landing-project.project_id +# zone = "europe-west1-b" +# name = "test-vm-0" +# network_interfaces = [{ +# network = module.landing-vpc.self_link +# subnetwork = module.landing-vpc.subnet_self_links["europe-west1/landing-default-ew1"] +# alias_ips = {} +# nat = false +# addresses = null +# }] +# tags = ["ssh"] +# service_account_create = true +# boot_disk = { +# image = "projects/debian-cloud/global/images/family/debian-10" +# type = "pd-balanced" +# size = 10 +# } +# metadata = { +# startup-script = < v.adv == null ? null : { + advertise_groups = [] + advertise_ip_ranges = { + for adv in(v.adv == null ? [] : v.adv.custom) : + var.custom_adv[adv] => adv + } + advertise_mode = try(v.adv.default, false) ? "DEFAULT" : "CUSTOM" + route_priority = null + } + } +} + +module "landing-to-onprem-ew1-vpn" { + count = local.enable_onprem_vpn ? 1 : 0 + source = "../../../modules/net-vpn-ha" + project_id = module.landing-project.project_id + network = module.landing-vpc.self_link + region = "europe-west1" + name = "vpn-to-onprem-ew1" + router_create = true + router_name = "landing-onprem-vpn-ew1" + router_asn = var.router_onprem_configs.landing-ew1.asn + peer_external_gateway = var.vpn_onprem_configs.landing-ew1.peer_external_gateway + tunnels = { + for t in var.vpn_onprem_configs.landing-ew1.tunnels : + "remote-${t.vpn_gateway_interface}-${t.peer_external_gateway_interface}" => { + bgp_peer = { + address = cidrhost(t.session_range, 1) + asn = t.peer_asn + } + bgp_peer_options = local.bgp_peer_options_onprem.landing-ew1 + bgp_session_range = "${cidrhost(t.session_range, 2)}/30" + ike_version = 2 + peer_external_gateway_interface = t.peer_external_gateway_interface + router = null + shared_secret = t.secret + vpn_gateway_interface = t.vpn_gateway_interface + } + } +} diff --git a/fast/stages/02-networking-vpn/README.md b/fast/stages/02-networking-vpn/README.md index 5a96bf134..6074d2af7 100644 --- a/fast/stages/02-networking-vpn/README.md +++ b/fast/stages/02-networking-vpn/README.md @@ -301,6 +301,7 @@ DNS configurations are centralised in the `dns.tf` file. Spokes delegate DNS res | [spoke-dev.tf](./spoke-dev.tf) | Dev spoke VPC and related resources. | net-cloudnat · net-vpc · net-vpc-firewall · project | google_project_iam_binding | | [spoke-prod.tf](./spoke-prod.tf) | Production spoke VPC and related resources. | net-cloudnat · net-vpc · net-vpc-firewall · project | google_project_iam_binding | | [test-resources.tf](./test-resources.tf) | temporary instances for testing | compute-vm | | +| [variables-vpn.tf](./variables-vpn.tf) | None | | | | [variables.tf](./variables.tf) | Module variables. | | | | [vpn-onprem.tf](./vpn-onprem.tf) | VPN between landing and onprem. | net-vpn-ha | | | [vpn-spoke-dev.tf](./vpn-spoke-dev.tf) | VPN between landing and development spoke. | net-vpn-ha | | @@ -321,10 +322,11 @@ DNS configurations are centralised in the `dns.tf` file. Spokes delegate DNS res | [l7ilb_subnets](variables.tf#L76) | Subnets used for L7 ILBs. | map(list(object({…}))) | | {…} | | | [outputs_location](variables.tf#L104) | Path where providers and tfvars files for the following stages are written. Leave empty to disable. | string | | null | | | [psa_ranges](variables.tf#L121) | IP ranges used for Private Service Access (e.g. CloudSQL). | map(map(string)) | | {…} | | -| [router_configs](variables.tf#L136) | Configurations for CRs and onprem routers. | map(object({…})) | | {…} | | -| [service_accounts](variables.tf#L160) | Automation service accounts in name => email format. | object({…}) | | null | 01-resman | -| [vpn_onprem_configs](variables.tf#L172) | VPN gateway configuration for onprem interconnection. | map(object({…})) | | {…} | | -| [vpn_spoke_configs](variables.tf#L228) | VPN gateway configuration for spokes. | map(object({…})) | | {…} | | +| [router_onprem_configs](variables.tf#L136) | Configurations for routers used for onprem connectivity. | map(object({…})) | | {…} | | +| [router_spoke_configs](variables-vpn.tf#L18) | Configurations for routers used for internal connectivity. | map(object({…})) | | {…} | | +| [service_accounts](variables.tf#L154) | Automation service accounts in name => email format. | object({…}) | | null | 01-resman | +| [vpn_onprem_configs](variables.tf#L166) | VPN gateway configuration for onprem interconnection. | map(object({…})) | | {…} | | +| [vpn_spoke_configs](variables-vpn.tf#L37) | VPN gateway configuration for spokes. | map(object({…})) | | {…} | | ## Outputs diff --git a/fast/stages/02-networking-vpn/data/firewall-rules/dev/rules.yaml b/fast/stages/02-networking-vpn/data/firewall-rules/dev/rules.yaml index f2d614a59..d4df8cdc3 100644 --- a/fast/stages/02-networking-vpn/data/firewall-rules/dev/rules.yaml +++ b/fast/stages/02-networking-vpn/data/firewall-rules/dev/rules.yaml @@ -5,7 +5,7 @@ ingress-allow-composer-nodes: direction: INGRESS action: allow sources: [] - ranges: [] + ranges: ["0.0.0.0/0"] targets: - composer-worker use_service_accounts: false @@ -18,7 +18,7 @@ ingress-allow-dataflow-load: direction: INGRESS action: allow sources: [] - ranges: [] + ranges: ["0.0.0.0/0"] targets: - dataflow use_service_accounts: false diff --git a/fast/stages/02-networking-vpn/dns-dev.tf b/fast/stages/02-networking-vpn/dns-dev.tf index 3c81a93fc..aad50afc3 100644 --- a/fast/stages/02-networking-vpn/dns-dev.tf +++ b/fast/stages/02-networking-vpn/dns-dev.tf @@ -20,11 +20,11 @@ module "dev-dns-private-zone" { source = "../../../modules/dns" - project_id = module.landing-project.project_id + project_id = module.dev-spoke-project.project_id type = "private" name = "dev-gcp-example-com" domain = "dev.gcp.example.com." - client_networks = [module.dev-spoke-vpc.self_link] + client_networks = [module.landing-vpc.self_link] recordsets = { "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } } diff --git a/fast/stages/02-networking-vpn/dns-landing.tf b/fast/stages/02-networking-vpn/dns-landing.tf index 611410b5e..b1d766ab2 100644 --- a/fast/stages/02-networking-vpn/dns-landing.tf +++ b/fast/stages/02-networking-vpn/dns-landing.tf @@ -50,28 +50,6 @@ module "gcp-example-dns-private-zone" { } } -# GCP-specific DNS zones peered to the environment spoke that holds the config - -module "prod-gcp-example-dns-peering" { - source = "../../../modules/dns" - project_id = module.landing-project.project_id - type = "peering" - name = "prod-root-dns-peering" - domain = "prod.gcp.example.com." - client_networks = [module.landing-vpc.self_link] - peer_network = module.prod-spoke-vpc.self_link -} - -module "dev-gcp-example-dns-peering" { - source = "../../../modules/dns" - project_id = module.landing-project.project_id - type = "peering" - name = "dev-root-dns-peering" - domain = "dev.gcp.example.com." - client_networks = [module.landing-vpc.self_link] - peer_network = module.dev-spoke-vpc.self_link -} - # Google API zone to trigger Private Access module "googleapis-private-zone" { diff --git a/fast/stages/02-networking-vpn/dns-prod.tf b/fast/stages/02-networking-vpn/dns-prod.tf index 22977348b..a4a916b46 100644 --- a/fast/stages/02-networking-vpn/dns-prod.tf +++ b/fast/stages/02-networking-vpn/dns-prod.tf @@ -20,11 +20,11 @@ module "prod-dns-private-zone" { source = "../../../modules/dns" - project_id = module.landing-project.project_id + project_id = module.prod-spoke-project.project_id type = "private" name = "prod-gcp-example-com" domain = "prod.gcp.example.com." - client_networks = [module.prod-spoke-vpc.self_link] + client_networks = [module.landing-vpc.self_link] recordsets = { "A localhost" = { type = "A", ttl = 300, records = ["127.0.0.1"] } } diff --git a/fast/stages/02-networking-vpn/main.tf b/fast/stages/02-networking-vpn/main.tf index 964b7dfc3..5df6d604e 100644 --- a/fast/stages/02-networking-vpn/main.tf +++ b/fast/stages/02-networking-vpn/main.tf @@ -17,19 +17,6 @@ # tfdoc:file:description Networking folder and hierarchical policy. locals { - # define the structures used for BGP peers in the VPN resources - bgp_peer_options = { - for k, v in var.vpn_spoke_configs : - k => v.adv == null ? null : { - advertise_groups = [] - advertise_ip_ranges = { - for adv in(v.adv == null ? [] : v.adv.custom) : - var.custom_adv[adv] => adv - } - advertise_mode = try(v.adv.default, false) ? "DEFAULT" : "CUSTOM" - route_priority = null - } - } custom_roles = coalesce(var.custom_roles, {}) l7ilb_subnets = { for env, v in var.l7ilb_subnets : env => [ diff --git a/fast/stages/02-networking-vpn/outputs.tf b/fast/stages/02-networking-vpn/outputs.tf index 78f69fc2d..3fe18d657 100644 --- a/fast/stages/02-networking-vpn/outputs.tf +++ b/fast/stages/02-networking-vpn/outputs.tf @@ -76,9 +76,9 @@ output "shared_vpc_self_links" { output "vpn_gateway_endpoints" { description = "External IP Addresses for the GCP VPN gateways." - value = { + value = local.enable_onprem_vpn == false ? null : { onprem-ew1 = { - for v in module.landing-to-onprem-ew1-vpn.gateway.vpn_interfaces : + for v in module.landing-to-onprem-ew1-vpn[0].gateway.vpn_interfaces : v.id => v.ip_address } } diff --git a/fast/stages/02-networking-vpn/variables-vpn.tf b/fast/stages/02-networking-vpn/variables-vpn.tf new file mode 100644 index 000000000..1732a3a1e --- /dev/null +++ b/fast/stages/02-networking-vpn/variables-vpn.tf @@ -0,0 +1,88 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +variable "router_spoke_configs" { + description = "Configurations for routers used for internal connectivity." + type = map(object({ + adv = object({ + custom = list(string) + default = bool + }) + asn = number + })) + default = { + landing-ew1 = { asn = "64512", adv = null } + landing-ew4 = { asn = "64512", adv = null } + spoke-dev-ew1 = { asn = "64513", adv = null } + spoke-dev-ew4 = { asn = "64513", adv = null } + spoke-prod-ew1 = { asn = "64514", adv = null } + spoke-prod-ew4 = { asn = "64514", adv = null } + } +} + +variable "vpn_spoke_configs" { + description = "VPN gateway configuration for spokes." + type = map(object({ + adv = object({ + default = bool + custom = list(string) + }) + session_range = string + })) + default = { + landing-ew1 = { + adv = { + default = false + custom = ["rfc_1918_10", "rfc_1918_172", "rfc_1918_192"] + } + # values for the landing router are pulled from the spoke range + session_range = null + } + landing-ew4 = { + adv = { + default = false + custom = ["rfc_1918_10", "rfc_1918_172", "rfc_1918_192"] + } + # values for the landing router are pulled from the spoke range + session_range = null + } + dev-ew1 = { + adv = { + default = false + custom = ["gcp_dev"] + } + # resize according to required number of tunnels + session_range = "169.254.0.0/27" + } + prod-ew1 = { + adv = { + default = false + custom = ["gcp_prod"] + } + # resize according to required number of tunnels + session_range = "169.254.0.64/27" + } + prod-ew4 = { + adv = { + default = false + custom = ["gcp_prod"] + } + # resize according to required number of tunnels + session_range = "169.254.0.96/27" + } + } +} diff --git a/fast/stages/02-networking-vpn/variables.tf b/fast/stages/02-networking-vpn/variables.tf index 8f8788f9d..426dc718f 100644 --- a/fast/stages/02-networking-vpn/variables.tf +++ b/fast/stages/02-networking-vpn/variables.tf @@ -133,8 +133,8 @@ variable "psa_ranges" { } } -variable "router_configs" { - description = "Configurations for CRs and onprem routers." +variable "router_onprem_configs" { + description = "Configurations for routers used for onprem connectivity." type = map(object({ adv = object({ custom = list(string) @@ -143,17 +143,11 @@ variable "router_configs" { asn = number })) default = { - onprem-ew1 = { - asn = "65534" + landing-ew1 = { + asn = "65533" adv = null # adv = { default = false, custom = [] } } - landing-ew1 = { asn = "64512", adv = null } - landing-ew4 = { asn = "64512", adv = null } - spoke-dev-ew1 = { asn = "64513", adv = null } - spoke-dev-ew4 = { asn = "64513", adv = null } - spoke-prod-ew1 = { asn = "64514", adv = null } - spoke-prod-ew4 = { asn = "64514", adv = null } } } @@ -224,56 +218,3 @@ variable "vpn_onprem_configs" { } } } - -variable "vpn_spoke_configs" { - description = "VPN gateway configuration for spokes." - type = map(object({ - adv = object({ - default = bool - custom = list(string) - }) - session_range = string - })) - default = { - landing-ew1 = { - adv = { - default = false - custom = ["rfc_1918_10", "rfc_1918_172", "rfc_1918_192"] - } - # values for the landing router are pulled from the spoke range - session_range = null - } - landing-ew4 = { - adv = { - default = false - custom = ["rfc_1918_10", "rfc_1918_172", "rfc_1918_192"] - } - # values for the landing router are pulled from the spoke range - session_range = null - } - dev-ew1 = { - adv = { - default = false - custom = ["gcp_dev"] - } - # resize according to required number of tunnels - session_range = "169.254.0.0/27" - } - prod-ew1 = { - adv = { - default = false - custom = ["gcp_prod"] - } - # resize according to required number of tunnels - session_range = "169.254.0.64/27" - } - prod-ew4 = { - adv = { - default = false - custom = ["gcp_prod"] - } - # resize according to required number of tunnels - session_range = "169.254.0.96/27" - } - } -} diff --git a/fast/stages/02-networking-vpn/vpn-onprem.tf b/fast/stages/02-networking-vpn/vpn-onprem.tf index 859e1a770..48cad54b3 100644 --- a/fast/stages/02-networking-vpn/vpn-onprem.tf +++ b/fast/stages/02-networking-vpn/vpn-onprem.tf @@ -17,7 +17,8 @@ # tfdoc:file:description VPN between landing and onprem. locals { - bgp_peer_options_onprem = { + enable_onprem_vpn = var.vpn_onprem_configs != null + bgp_peer_options_onprem = local.enable_onprem_vpn == false ? null : { for k, v in var.vpn_onprem_configs : k => v.adv == null ? null : { advertise_groups = [] @@ -32,6 +33,7 @@ locals { } module "landing-to-onprem-ew1-vpn" { + count = local.enable_onprem_vpn ? 1 : 0 source = "../../../modules/net-vpn-ha" project_id = module.landing-project.project_id network = module.landing-vpc.self_link @@ -39,7 +41,7 @@ module "landing-to-onprem-ew1-vpn" { name = "vpn-to-onprem-ew1" router_create = true router_name = "landing-onprem-vpn-ew1" - router_asn = var.router_configs.landing-ew1.asn + router_asn = var.router_onprem_configs.landing-ew1.asn peer_external_gateway = var.vpn_onprem_configs.landing-ew1.peer_external_gateway tunnels = { for t in var.vpn_onprem_configs.landing-ew1.tunnels : diff --git a/fast/stages/02-networking-vpn/vpn-spoke-dev.tf b/fast/stages/02-networking-vpn/vpn-spoke-dev.tf index edfe4000b..01d43d479 100644 --- a/fast/stages/02-networking-vpn/vpn-spoke-dev.tf +++ b/fast/stages/02-networking-vpn/vpn-spoke-dev.tf @@ -16,6 +16,24 @@ # tfdoc:file:description VPN between landing and development spoke. +locals { + # define the structures used for BGP peers in the VPN resources + vpn_spoke_bgp_peer_options = { + for k, v in var.vpn_spoke_configs : + k => v.adv == null ? null : { + advertise_groups = [] + advertise_ip_ranges = { + for adv in(v.adv == null ? [] : v.adv.custom) : + var.custom_adv[adv] => adv + } + advertise_mode = try(v.adv.default, false) ? "DEFAULT" : "CUSTOM" + route_priority = null + } + } +} + +# development spoke + module "landing-to-dev-ew1-vpn" { source = "../../../modules/net-vpn-ha" project_id = module.landing-project.project_id @@ -25,15 +43,17 @@ module "landing-to-dev-ew1-vpn" { # The router used for this VPN is managed in vpn-prod.tf router_create = false router_name = "landing-vpn-ew1" - router_asn = var.router_configs.landing-ew1.asn + router_asn = var.router_spoke_configs.landing-ew1.asn peer_gcp_gateway = module.dev-to-landing-ew1-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { address = cidrhost(var.vpn_spoke_configs.dev-ew1.session_range, 1 + (t * 4)) - asn = var.router_configs.spoke-dev-ew1.asn + asn = var.router_spoke_configs.spoke-dev-ew1.asn } - bgp_peer_options = local.bgp_peer_options["landing-ew1"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.dev-ew1.session_range, 2 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.landing-ew1 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.dev-ew1.session_range, 2 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null @@ -54,15 +74,17 @@ module "dev-to-landing-ew1-vpn" { name = "vpn-to-landing-ew1" router_create = true router_name = "dev-spoke-vpn-ew1" - router_asn = var.router_configs.spoke-dev-ew1.asn + router_asn = var.router_spoke_configs.spoke-dev-ew1.asn peer_gcp_gateway = module.landing-to-dev-ew1-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { address = cidrhost(var.vpn_spoke_configs.dev-ew1.session_range, 2 + (t * 4)) - asn = var.router_configs.landing-ew1.asn + asn = var.router_spoke_configs.landing-ew1.asn } - bgp_peer_options = local.bgp_peer_options["dev-ew1"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.dev-ew1.session_range, 1 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.dev-ew1 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.dev-ew1.session_range, 1 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null diff --git a/fast/stages/02-networking-vpn/vpn-spoke-prod.tf b/fast/stages/02-networking-vpn/vpn-spoke-prod.tf index a6d1ea166..ff635194c 100644 --- a/fast/stages/02-networking-vpn/vpn-spoke-prod.tf +++ b/fast/stages/02-networking-vpn/vpn-spoke-prod.tf @@ -16,6 +16,8 @@ # tfdoc:file:description VPN between landing and production spoke. +# local.vpn_spoke_bgp_peer_options is defined in the dev VPN file + module "landing-to-prod-ew1-vpn" { source = "../../../modules/net-vpn-ha" project_id = module.landing-project.project_id @@ -24,15 +26,19 @@ module "landing-to-prod-ew1-vpn" { name = "vpn-to-prod-ew1" router_create = true router_name = "landing-vpn-ew1" - router_asn = var.router_configs.landing-ew1.asn + router_asn = var.router_spoke_configs.landing-ew1.asn peer_gcp_gateway = module.prod-to-landing-ew1-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { - address = cidrhost(var.vpn_spoke_configs.prod-ew1.session_range, 1 + (t * 4)) - asn = var.router_configs.spoke-prod-ew1.asn + address = cidrhost( + var.vpn_spoke_configs.prod-ew1.session_range, 1 + (t * 4) + ) + asn = var.router_spoke_configs.spoke-prod-ew1.asn } - bgp_peer_options = local.bgp_peer_options["landing-ew1"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.prod-ew1.session_range, 2 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.landing-ew1 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.prod-ew1.session_range, 2 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null @@ -50,15 +56,19 @@ module "prod-to-landing-ew1-vpn" { name = "vpn-to-landing-ew1" router_create = true router_name = "prod-spoke-vpn-ew1" - router_asn = var.router_configs.spoke-prod-ew1.asn + router_asn = var.router_spoke_configs.spoke-prod-ew1.asn peer_gcp_gateway = module.landing-to-prod-ew1-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { - address = cidrhost(var.vpn_spoke_configs.prod-ew1.session_range, 2 + (t * 4)) - asn = var.router_configs.landing-ew1.asn + address = cidrhost( + var.vpn_spoke_configs.prod-ew1.session_range, 2 + (t * 4) + ) + asn = var.router_spoke_configs.landing-ew1.asn } - bgp_peer_options = local.bgp_peer_options["prod-ew1"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.prod-ew1.session_range, 1 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.prod-ew1 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.prod-ew1.session_range, 1 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null @@ -76,15 +86,19 @@ module "landing-to-prod-ew4-vpn" { name = "vpn-to-prod-ew4" router_create = true router_name = "landing-vpn-ew4" - router_asn = var.router_configs.landing-ew4.asn + router_asn = var.router_spoke_configs.landing-ew4.asn peer_gcp_gateway = module.prod-to-landing-ew4-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { - address = cidrhost(var.vpn_spoke_configs.prod-ew4.session_range, 1 + (t * 4)) - asn = var.router_configs.spoke-prod-ew4.asn + address = cidrhost( + var.vpn_spoke_configs.prod-ew4.session_range, 1 + (t * 4) + ) + asn = var.router_spoke_configs.spoke-prod-ew4.asn } - bgp_peer_options = local.bgp_peer_options["landing-ew4"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.prod-ew4.session_range, 2 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.landing-ew4 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.prod-ew4.session_range, 2 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null @@ -102,15 +116,19 @@ module "prod-to-landing-ew4-vpn" { name = "vpn-to-landing-ew4" router_create = true router_name = "prod-spoke-vpn-ew4" - router_asn = var.router_configs.spoke-prod-ew4.asn + router_asn = var.router_spoke_configs.spoke-prod-ew4.asn peer_gcp_gateway = module.landing-to-prod-ew4-vpn.self_link tunnels = { for t in range(2) : "tunnel-${t}" => { bgp_peer = { - address = cidrhost(var.vpn_spoke_configs.prod-ew4.session_range, 2 + (t * 4)) - asn = var.router_configs.landing-ew4.asn + address = cidrhost( + var.vpn_spoke_configs.prod-ew4.session_range, 2 + (t * 4) + ) + asn = var.router_spoke_configs.landing-ew4.asn } - bgp_peer_options = local.bgp_peer_options["prod-ew4"] - bgp_session_range = "${cidrhost(var.vpn_spoke_configs.prod-ew4.session_range, 1 + (t * 4))}/30" + bgp_peer_options = local.vpn_spoke_bgp_peer_options.prod-ew4 + bgp_session_range = "${cidrhost( + var.vpn_spoke_configs.prod-ew4.session_range, 1 + (t * 4) + )}/30" ike_version = 2 peer_external_gateway_interface = null router = null diff --git a/modules/cloud-config-container/coredns/README.md b/modules/cloud-config-container/coredns/README.md index 658f67345..09cc44916 100644 --- a/modules/cloud-config-container/coredns/README.md +++ b/modules/cloud-config-container/coredns/README.md @@ -79,11 +79,14 @@ module "cos-coredns" { | [coredns_config](variables.tf#L29) | CoreDNS configuration path, if null default will be used. | string | | null | | [file_defaults](variables.tf#L35) | Default owner and permissions for files. | object({…}) | | {…} | | [files](variables.tf#L47) | Map of extra files to create on the instance, path as key. Owner and permissions will use defaults if null. | map(object({…})) | | {} | +| [test_instance](variables-instance.tf#L17) | Test/development instance attributes, leave null to skip creation. | object({…}) | | null | +| [test_instance_defaults](variables-instance.tf#L30) | Test/development instance defaults used for optional configuration. If image is null, COS stable will be used. | object({…}) | | {…} | ## Outputs | name | description | sensitive | |---|---|:---:| | [cloud_config](outputs.tf#L17) | Rendered cloud-config file to be passed as user-data instance metadata. | | +| [test_instance](outputs-instance.tf#L17) | Optional test instance name and address. | | diff --git a/modules/cloud-config-container/mysql/README.md b/modules/cloud-config-container/mysql/README.md index 5e4623697..4c99d5042 100644 --- a/modules/cloud-config-container/mysql/README.md +++ b/modules/cloud-config-container/mysql/README.md @@ -86,11 +86,14 @@ module "cos-mysql" { | [kms_config](variables.tf#L35) | Optional KMS configuration to decrypt passed-in password. Leave null if a plaintext password is used. | object({…}) | | null | | [mysql_config](variables.tf#L46) | MySQL configuration file content, if null container default will be used. | string | | null | | [mysql_data_disk](variables.tf#L52) | MySQL data disk name in /dev/disk/by-id/ including the google- prefix. If null the boot disk will be used for data. | string | | null | +| [test_instance](variables-instance.tf#L17) | Test/development instance attributes, leave null to skip creation. | object({…}) | | null | +| [test_instance_defaults](variables-instance.tf#L30) | Test/development instance defaults used for optional configuration. If image is null, COS stable will be used. | object({…}) | | {…} | ## Outputs | name | description | sensitive | |---|---|:---:| | [cloud_config](outputs.tf#L17) | Rendered cloud-config file to be passed as user-data instance metadata. | | +| [test_instance](outputs-instance.tf#L17) | Optional test instance name and address. | | diff --git a/modules/cloud-config-container/nginx/README.md b/modules/cloud-config-container/nginx/README.md index 2b2923920..c993eb72e 100644 --- a/modules/cloud-config-container/nginx/README.md +++ b/modules/cloud-config-container/nginx/README.md @@ -63,11 +63,14 @@ module "cos-nginx" { | [files](variables.tf#L53) | Map of extra files to create on the instance, path as key. Owner and permissions will use defaults if null. | map(object({…})) | | {} | | [image](variables.tf#L29) | Nginx container image. | string | | "nginxdemos/hello:plain-text" | | [nginx_config](variables.tf#L35) | Nginx configuration path, if null container default will be used. | string | | null | +| [test_instance](variables-instance.tf#L17) | Test/development instance attributes, leave null to skip creation. | object({…}) | | null | +| [test_instance_defaults](variables-instance.tf#L30) | Test/development instance defaults used for optional configuration. If image is null, COS stable will be used. | object({…}) | | {…} | ## Outputs | name | description | sensitive | |---|---|:---:| | [cloud_config](outputs.tf#L17) | Rendered cloud-config file to be passed as user-data instance metadata. | | +| [test_instance](outputs-instance.tf#L17) | Optional test instance name and address. | | diff --git a/modules/cloud-config-container/onprem/README.md b/modules/cloud-config-container/onprem/README.md index 29aee5934..222d25b04 100644 --- a/modules/cloud-config-container/onprem/README.md +++ b/modules/cloud-config-container/onprem/README.md @@ -68,6 +68,8 @@ module "on-prem" { | [config_variables](variables.tf#L17) | Additional variables used to render the cloud-config and CoreDNS templates. | map(any) | | {} | | [coredns_config](variables.tf#L23) | CoreDNS configuration path, if null default will be used. | string | | null | | [local_ip_cidr_range](variables.tf#L29) | IP CIDR range used for the Docker onprem network. | string | | "192.168.192.0/24" | +| [test_instance](variables-instance.tf#L17) | Test/development instance attributes, leave null to skip creation. | object({…}) | | null | +| [test_instance_defaults](variables-instance.tf#L30) | Test/development instance defaults used for optional configuration. If image is null, COS stable will be used. | object({…}) | | {…} | | [vpn_dynamic_config](variables.tf#L46) | BGP configuration for dynamic VPN, ignored if VPN type is 'static'. | object({…}) | | {…} | | [vpn_static_ranges](variables.tf#L70) | Remote CIDR ranges for static VPN, ignored if VPN type is 'dynamic'. | list(string) | | ["10.0.0.0/8"] | @@ -76,5 +78,6 @@ module "on-prem" { | name | description | sensitive | |---|---|:---:| | [cloud_config](outputs.tf#L17) | Rendered cloud-config file to be passed as user-data instance metadata. | | +| [test_instance](outputs-instance.tf#L17) | Optional test instance name and address. | | diff --git a/modules/cloud-config-container/squid/README.md b/modules/cloud-config-container/squid/README.md index e1aa87168..912c52622 100644 --- a/modules/cloud-config-container/squid/README.md +++ b/modules/cloud-config-container/squid/README.md @@ -70,11 +70,14 @@ module "cos-squid" { | [file_defaults](variables.tf#L35) | Default owner and permissions for files. | object({…}) | | {…} | | [files](variables.tf#L47) | Map of extra files to create on the instance, path as key. Owner and permissions will use defaults if null. | map(object({…})) | | {} | | [squid_config](variables.tf#L29) | Squid configuration path, if null default will be used. | string | | null | +| [test_instance](variables-instance.tf#L17) | Test/development instance attributes, leave null to skip creation. | object({…}) | | null | +| [test_instance_defaults](variables-instance.tf#L30) | Test/development instance defaults used for optional configuration. If image is null, COS stable will be used. | object({…}) | | {…} | ## Outputs | name | description | sensitive | |---|---|:---:| | [cloud_config](outputs.tf#L17) | Rendered cloud-config file to be passed as user-data instance metadata. | | +| [test_instance](outputs-instance.tf#L17) | Optional test instance name and address. | | diff --git a/modules/net-vpc-firewall/main.tf b/modules/net-vpc-firewall/main.tf index ef4767d3a..c08b2d165 100644 --- a/modules/net-vpc-firewall/main.tf +++ b/modules/net-vpc-firewall/main.tf @@ -124,14 +124,22 @@ resource "google_compute_firewall" "allow-tag-https" { resource "google_compute_firewall" "custom-rules" { # provider = "google-beta" - for_each = local.custom_rules - name = each.key - description = each.value.description - direction = each.value.direction - network = var.network - project = var.project_id - source_ranges = each.value.direction == "INGRESS" ? each.value.ranges : null - destination_ranges = each.value.direction == "EGRESS" ? each.value.ranges : null + for_each = local.custom_rules + name = each.key + description = each.value.description + direction = each.value.direction + network = var.network + project = var.project_id + source_ranges = ( + each.value.direction == "INGRESS" + ? coalesce(each.value.ranges, []) == [] ? ["0.0.0.0/0"] : each.value.ranges + : null + ) + destination_ranges = ( + each.value.direction == "EGRESS" + ? coalesce(each.value.ranges, []) == [] ? ["0.0.0.0/0"] : each.value.ranges + : null + ) source_tags = each.value.use_service_accounts || each.value.direction == "EGRESS" ? null : each.value.sources source_service_accounts = each.value.use_service_accounts && each.value.direction == "INGRESS" ? each.value.sources : null target_tags = each.value.use_service_accounts ? null : each.value.targets diff --git a/tests/fast/stages/s02_networking_peering/__init__.py b/tests/fast/stages/s02_networking_peering/__init__.py new file mode 100644 index 000000000..6d6d1266c --- /dev/null +++ b/tests/fast/stages/s02_networking_peering/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/fast/stages/s02_networking_peering/fixture/main.tf b/tests/fast/stages/s02_networking_peering/fixture/main.tf new file mode 100644 index 000000000..b06bad39f --- /dev/null +++ b/tests/fast/stages/s02_networking_peering/fixture/main.tf @@ -0,0 +1,44 @@ +/** + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +module "stage" { + source = "../../../../../fast/stages/02-networking-peering" + data_dir = "../../../../../fast/stages/02-networking-peering/data/" + billing_account = { + id = "000000-111111-222222" + organization_id = 123456789012 + } + custom_roles = { + service_project_network_admin = "organizations/123456789012/roles/foo" + } + folder_ids = { + networking = null + networking-dev = null + networking-prod = null + } + service_accounts = { + data-platform-dev = "string" + data-platform-prod = "string" + project-factory-dev = "string" + project-factory-prod = "string" + } + organization = { + domain = "fast.example.com" + id = 123456789012 + customer_id = "C00000000" + } + prefix = "fast2" +} diff --git a/tests/fast/stages/s02_networking_peering/test_plan.py b/tests/fast/stages/s02_networking_peering/test_plan.py new file mode 100644 index 000000000..6189f62e3 --- /dev/null +++ b/tests/fast/stages/s02_networking_peering/test_plan.py @@ -0,0 +1,20 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_counts(fast_e2e_plan_runner): + "Test stage." + num_modules, num_resources, _ = fast_e2e_plan_runner() + # TODO: to re-enable per-module resource count check print _, then test + assert num_modules > 0 and num_resources > 0 diff --git a/tests/fast/stages/s02_networking_vpn/fixture/main.tf b/tests/fast/stages/s02_networking_vpn/fixture/main.tf index 58a8d6c0d..9a736685d 100644 --- a/tests/fast/stages/s02_networking_vpn/fixture/main.tf +++ b/tests/fast/stages/s02_networking_vpn/fixture/main.tf @@ -14,22 +14,6 @@ * limitations under the License. */ -# module "stage" { -# source = "../../../../../fast/stages/02-networking-vpn" -# billing_account_id = "000000-111111-222222" -# organization = { -# domain = "gcp-pso-italy.net" -# id = 856933387836 -# customer_id = "C01lmug8b" -# } -# prefix = "fast" -# project_factory_sa = { -# dev = "foo@iam" -# prod = "bar@iam" -# } -# data_dir = "../../../../../fast/stages/02-networking-vpn/data/" -# } - module "stage" { source = "../../../../../fast/stages/02-networking-vpn" data_dir = "../../../../../fast/stages/02-networking-vpn/data/" diff --git a/tools/check_documentation.py b/tools/check_documentation.py index 1902570c1..2a6dd8ecf 100755 --- a/tools/check_documentation.py +++ b/tools/check_documentation.py @@ -79,11 +79,15 @@ def main(dirs, exclude_file=None, files=False, show_diffs=False, for mod_name, state, diff in _check_dir(dir_name, exclude_file, files, show_extra): if state == State.FAIL: - errors.append(diff) + errors.append((mod_name, diff)) print(f'[{state_labels[state]}] {mod_name}') if errors: if show_diffs: - print('\n'.join(errors)) + print('Errored diffs:') + print('\n'.join([e[1] for e in errors])) + else: + print('Errored modules:') + print('\n'.join([e[0] for e in errors])) raise SystemExit('Errors found.') diff --git a/tools/tfdoc.py b/tools/tfdoc.py index 0e847d340..68bbe00cc 100755 --- a/tools/tfdoc.py +++ b/tools/tfdoc.py @@ -100,10 +100,11 @@ VAR_RE_TYPE = re.compile(r'([\(\{\}\)])') VAR_TEMPLATE = ('default', 'description', 'type', 'nullable') File = collections.namedtuple('File', 'name description modules resources') -Output = collections.namedtuple('Output', - 'name description sensitive consumers line') +Output = collections.namedtuple( + 'Output', 'name description sensitive consumers file line') Variable = collections.namedtuple( - 'Variable', 'name description type default required nullable source line') + 'Variable', + 'name description type default required nullable source file line') # parsing functions @@ -171,41 +172,52 @@ def parse_files(basepath, exclude_files=None): yield File(shortname, description, modules, resources) -def parse_outputs(basepath): - 'Return a list of Output named tuples for root module outputs.tf.' - try: - with open(os.path.join(basepath, 'outputs.tf')) as file: - body = file.read() - except (IOError, OSError) as e: - raise SystemExit(f'No outputs file in {basepath}.') - for item in _parse(body, enum=OUT_ENUM, re=OUT_RE, template=OUT_TEMPLATE): - description = ''.join(item['description']) - sensitive = item['sensitive'] != [] - consumers = item['tags'].get('output:consumers', '') - yield Output(name=item['name'], description=description, - sensitive=sensitive, consumers=consumers, line=item['line']) +def parse_outputs(basepath, exclude_files=None): + 'Return a list of Output named tuples for root module outputs*.tf.' + exclude_files = exclude_files or [] + for name in glob.glob(os.path.join(basepath, 'outputs*tf')): + shortname = os.path.basename(name) + if shortname in exclude_files: + continue + try: + with open(name) as file: + body = file.read() + except (IOError, OSError) as e: + raise SystemExit(f'Cannot open outputs file {shortname}.') + for item in _parse(body, enum=OUT_ENUM, re=OUT_RE, template=OUT_TEMPLATE): + description = ''.join(item['description']) + sensitive = item['sensitive'] != [] + consumers = item['tags'].get('output:consumers', '') + yield Output(name=item['name'], description=description, + sensitive=sensitive, consumers=consumers, file=shortname, + line=item['line']) -def parse_variables(basepath): - 'Return a list of Output named tuples for root module variables.tf.' - try: - with open(os.path.join(basepath, 'variables.tf')) as file: - body = file.read() - except (IOError, OSError) as e: - raise SystemExit(f'No variables file in {basepath}.') - for item in _parse(body): - description = ''.join(item['description']) - vtype = '\n'.join(item['type']) - default = HEREDOC_RE.sub(r'\1', '\n'.join(item['default'])) - required = not item['default'] - nullable = item.get('nullable') != ['false'] - source = item['tags'].get('variable:source', '') - if not required and default != 'null' and vtype == 'string': - default = f'"{default}"' +def parse_variables(basepath, exclude_files=None): + 'Return a list of Variable named tuples for root module variables*.tf.' + exclude_files = exclude_files or [] + for name in glob.glob(os.path.join(basepath, 'variables*tf')): + shortname = os.path.basename(name) + if shortname in exclude_files: + continue + try: + with open(name) as file: + body = file.read() + except (IOError, OSError) as e: + raise SystemExit(f'Cannot open variables file {shortname}.') + for item in _parse(body): + description = ''.join(item['description']) + vtype = '\n'.join(item['type']) + default = HEREDOC_RE.sub(r'\1', '\n'.join(item['default'])) + required = not item['default'] + nullable = item.get('nullable') != ['false'] + source = item['tags'].get('variable:source', '') + if not required and default != 'null' and vtype == 'string': + default = f'"{default}"' - yield Variable(name=item['name'], description=description, type=vtype, - default=default, required=required, source=source, - line=item['line'], nullable=nullable) + yield Variable(name=item['name'], description=description, type=vtype, + default=default, required=required, source=source, + file=shortname, line=item['line'], nullable=nullable) # formatting functions @@ -268,7 +280,7 @@ def format_outputs(items, show_extra=True): if consumers: consumers = '%s' % ' · '.join(consumers.split()) sensitive = '✓' if i.sensitive else '' - format = f'| [{i.name}](outputs.tf#L{i.line}) | {i.description or ""} | {sensitive} |' + format = f'| [{i.name}]({i.file}#L{i.line}) | {i.description or ""} | {sensitive} |' format += f' {consumers} |' if show_extra else '' yield format @@ -301,7 +313,7 @@ def format_variables(items, show_extra=True): value = f'{value[0]}…{value[-1].strip()}' vars[k] = f'{_escape(value)}' format = ( - f'| [{i.name}](variables.tf#L{i.line}) | {i.description or ""} | {vars["type"]} ' + f'| [{i.name}]({i.file}#L{i.line}) | {i.description or ""} | {vars["type"]} ' f'| {vars["required"]} | {vars["default"]} |') format += f' {vars["source"]} |' if show_extra else '' yield format @@ -342,8 +354,8 @@ def create_doc(module_path, files=False, show_extra=False, exclude_files=None, show_extra = opts.get('show_extra', show_extra) try: mod_files = list(parse_files(module_path, exclude_files)) if files else [] - mod_variables = list(parse_variables(module_path)) - mod_outputs = list(parse_outputs(module_path)) + mod_variables = list(parse_variables(module_path, exclude_files)) + mod_outputs = list(parse_outputs(module_path, exclude_files)) except (IOError, OSError) as e: raise SystemExit(e) return format_doc(mod_outputs, mod_variables, mod_files, show_extra)