One Contract, Two Clouds: A Network Baseline on AWS and Azure with Terraform

Every organisation that ends up on two clouds meets the same proposal within a quarter: write one Terraform module that works on both. Give it a cloud variable, hide the differences, and teams never need to know which provider they are on. It is an attractive idea, and it produces modules that are harder to use than either cloud’s own resources.

The clouds are not the same thing with different names. A VPC and a virtual network differ in how subnets relate to zones, how outbound access works, what a security boundary is, how logs are captured and where they go. A module that hides those differences either exposes the lowest common denominator or grows a second, private configuration language for each cloud. This article takes a different line: share the contract, not the resources. One slice of a landing zone, a private network with logging, is built twice, once per cloud, behind the same inputs, outputs and naming rules, and the same contract tests run against both. The switch-module design is built as well, so its cost can be measured and not just asserted.

Requirements

  • One interface for consumers. A platform pipeline or a calling module passes the same inputs and reads the same outputs whichever cloud it targets.
  • Each cloud’s controls done properly. No default outbound access on Azure subnets. No public IP assignment and a deny-all default security group on AWS. Network logs kept, encrypted, private and expired on both.
  • The interface cannot drift. If one implementation relaxes a validation rule or renames an output, a build fails.
  • A team on one cloud pays nothing for the other. No second provider to download, configure, lock and upgrade.
  • Testable with no cloud account, on Terraform and on OpenTofu.
Three panels: an AWS module with VPC, subnets, default security group, flow logs and an encrypted bucket; a shared contract of variables, outputs, locals and nine contract tests; and an Azure module with resource group, virtual network, subnets, diagnostics and a locked-down storage account.
The contract in the middle is the only shared code. Each implementation uses its own cloud’s resources and has its own tests for them.

The contract

The contract is three small files. The first is the inputs, with their validation rules:

# The contract. This file is identical in every implementation; CI fails if the copies drift.

variable "name" {
  description = "Short workload name, used in resource names."
  type        = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9]{2,11}$", var.name))
    error_message = "name must be 3 to 12 lower-case letters and digits, starting with a letter."
  }
}

variable "environment" {
  description = "Deployment environment."
  type        = string

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "environment must be dev, test or prod."
  }
}

variable "region" {
  description = "Region, in the cloud's own naming. This is deliberately not abstracted."
  type        = string
}

variable "address_space" {
  description = "IPv4 CIDR block of the network, between /16 and /24."
  type        = string

  validation {
    condition     = can(cidrhost(var.address_space, 0)) && try(tonumber(split("/", var.address_space)[1]) >= 16 && tonumber(split("/", var.address_space)[1]) <= 24, false)
    error_message = "address_space must be an IPv4 CIDR block between /16 and /24."
  }
}

variable "subnet_count" {
  description = "Number of private subnets."
  type        = number
  default     = 2

  validation {
    condition     = var.subnet_count >= 1 && var.subnet_count <= 4 && floor(var.subnet_count) == var.subnet_count
    error_message = "subnet_count must be a whole number from 1 to 4."
  }
}

variable "log_retention_days" {
  description = "Days that network logs are kept."
  type        = number
  default     = 365

  validation {
    condition     = var.log_retention_days >= 90
    error_message = "log_retention_days must be at least 90."
  }
}

variable "tags" {
  description = "Extra tags for every resource."
  type        = map(string)
  default     = {}
}

The region variable has no validation, and that is a decision. eu-west-1 and northeurope are not two spellings of one thing; they are different places with different service availability and different data-residency meaning. Mapping them behind a neutral name such as “ireland” would hide a fact the caller has to own.

The second file is the outputs. Identifiers are returned in each cloud’s own format, a short VPC id on one side and a long resource id path on the other. The contract promises that there is a network id, not what it looks like.

# The contract. This file is identical in every implementation; CI fails if the copies drift.

output "network_id" {
  description = "Identifier of the virtual network, in the cloud's own format."
  value       = local.network_id
}

output "subnet_ids" {
  description = "Subnet identifiers by subnet name."
  value       = local.subnet_ids
}

output "subnet_cidrs" {
  description = "Subnet CIDR blocks by subnet name."
  value       = local.subnets
}

output "log_store_id" {
  description = "Identifier of the store that receives network logs."
  value       = local.log_store_id
}

output "tags" {
  description = "The tags applied to every resource."
  value       = local.tags
}

The third is the derivations that must agree: names, tags and subnet arithmetic.

# Shared derivations. This file is identical in every implementation; CI fails if the copies drift.

locals {
  prefix = "${var.name}-${var.environment}"

  # Two bits of subnetting: up to four equal subnets, named the same way on every cloud.
  subnets = {
    for index in range(var.subnet_count) :
    format("private-%d", index + 1) => cidrsubnet(var.address_space, 2, index)
  }

  tags = merge(var.tags, {
    workload    = var.name
    environment = var.environment
    managed-by  = "terraform"
  })
}

Two implementations

The AWS module:

# AWS implementation of the network baseline contract: a VPC with private subnets, a default
# security group that allows nothing, and VPC flow logs delivered to an encrypted S3 bucket.

resource "aws_vpc" "this" {
  cidr_block           = var.address_space
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags                 = merge(local.tags, { Name = "vpc-${local.prefix}" })
}

resource "aws_subnet" "private" {
  for_each = local.subnets

  vpc_id                  = aws_vpc.this.id
  cidr_block              = each.value
  map_public_ip_on_launch = false
  tags                    = merge(local.tags, { Name = "snet-${local.prefix}-${each.key}" })
}

# Adopting the default security group with no rules removes its allow-all defaults.
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
  tags   = merge(local.tags, { Name = "default-deny-${local.prefix}" })
}

resource "aws_s3_bucket" "logs" {
  bucket_prefix = "netlogs-${local.prefix}-"
  tags          = local.tags
}

resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "logs" {
  bucket = aws_s3_bucket.logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_lifecycle_configuration" "logs" {
  bucket = aws_s3_bucket.logs.id

  rule {
    id     = "expire-network-logs"
    status = "Enabled"

    filter {}

    expiration {
      days = var.log_retention_days
    }
  }
}

resource "aws_flow_log" "vpc" {
  vpc_id               = aws_vpc.this.id
  traffic_type         = "ALL"
  log_destination_type = "s3"
  log_destination      = aws_s3_bucket.logs.arn
  tags                 = local.tags
}

locals {
  network_id   = aws_vpc.this.id
  subnet_ids   = { for name, subnet in aws_subnet.private : name => subnet.id }
  log_store_id = aws_s3_bucket.logs.arn
}

The Azure module:

# Azure implementation of the network baseline contract: a resource group, a virtual network with
# private subnets that have no default outbound access, and diagnostics delivered to a locked-down
# storage account.

resource "azurerm_resource_group" "this" {
  name     = "rg-${local.prefix}-network"
  location = var.region
  tags     = local.tags
}

resource "azurerm_virtual_network" "this" {
  name                = "vnet-${local.prefix}"
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location
  address_space       = [var.address_space]
  tags                = local.tags
}

resource "azurerm_subnet" "private" {
  for_each = local.subnets

  name                            = "snet-${local.prefix}-${each.key}"
  resource_group_name             = azurerm_resource_group.this.name
  virtual_network_name            = azurerm_virtual_network.this.name
  address_prefixes                = [each.value]
  default_outbound_access_enabled = false
}

resource "azurerm_storage_account" "logs" {
  # Storage account names: 3 to 24 lower-case letters and digits, globally unique.
  name                = substr("stnetlogs${var.name}${var.environment}", 0, 24)
  resource_group_name = azurerm_resource_group.this.name
  location            = azurerm_resource_group.this.location

  account_tier                     = "Standard"
  account_replication_type         = "GRS"
  min_tls_version                  = "TLS1_2"
  https_traffic_only_enabled       = true
  allow_nested_items_to_be_public  = false
  shared_access_key_enabled        = false
  infrastructure_encryption_enabled = true
  tags                             = local.tags
}

resource "azurerm_storage_management_policy" "logs" {
  storage_account_id = azurerm_storage_account.logs.id

  rule {
    name    = "expire-network-logs"
    enabled = true

    filters {
      blob_types = ["blockBlob", "appendBlob"]
    }

    actions {
      base_blob {
        delete_after_days_since_modification_greater_than = var.log_retention_days
      }
    }
  }
}

resource "azurerm_monitor_diagnostic_setting" "network" {
  name               = "diag-${local.prefix}-network"
  target_resource_id = azurerm_virtual_network.this.id
  storage_account_id = azurerm_storage_account.logs.id

  enabled_log {
    category_group = "allLogs"
  }
}

locals {
  network_id   = azurerm_virtual_network.this.id
  subnet_ids   = { for name, subnet in azurerm_subnet.private : name => subnet.id }
  log_store_id = azurerm_storage_account.logs.id
}

Set side by side, the two files make the argument. They have the same shape at the top, a network and a for_each over the same subnet map, and then they part company. On AWS the secure default is to adopt the default security group with no rules and to switch off public IP assignment. On Azure it is to turn off default outbound access per subnet. AWS captures traffic with a flow log resource; Azure attaches a diagnostic setting. The log store is a bucket with four companion resources on one side and a single storage account with a management policy on the other. Each ends with a small locals block that maps its own resources onto the names the contract’s outputs expect. That mapping is the whole of the abstraction.

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#FFF3E0','primaryTextColor':'#232F3E','primaryBorderColor':'#FF9900','lineColor':'#232F3E','clusterBkg':'#F8FAFC','clusterBorder':'#CBD5E1','edgeLabelBackground':'#FFFFFF','nodeTextColor':'#232F3E'}}}%%
flowchart TD
    A[contract: variables, outputs, locals, contract tests] --> B[render.py copies the contract into each module]
    B --> C[network-baseline-aws]
    B --> D[network-baseline-azure]
    C --> E[9 contract tests plus 4 AWS tests, mocked provider]
    D --> F[9 contract tests plus 4 Azure tests, mocked provider]
    E --> G{render.py --check and all tests pass?}
    F --> G
    G -->|No| H[Build fails: the contract drifted or a control broke]
    G -->|Yes| I[Both implementations honour the same contract]
    classDef compute fill:#FFF3E0,stroke:#FF9900,stroke-width:2px,color:#232F3E
    classDef store fill:#F0FFF4,stroke:#1D8102,stroke-width:2px,color:#145202
    classDef danger fill:#FEF2F2,stroke:#D13212,stroke-width:2px,color:#991B1B
    classDef net fill:#EBF8FF,stroke:#0073BB,stroke-width:2px,color:#004C74
    classDef policy fill:#F3E8FF,stroke:#7C3AED,stroke-width:2px,color:#4C1D95
    class A,B policy
    class C,E,G compute
    class D,F net
    class H danger
    class I store

Keeping two copies of one contract honest

Terraform has no way for two modules to import a shared variables file, so the contract is copied into each module, and a script owns the copies.

"""Copy the contract into every implementation, or check that the copies are current.

    python contract/render.py            write the copies
    python contract/render.py --check    exit 1 if any copy differs from the contract
"""
import pathlib
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
CONTRACT = ROOT / "contract"
IMPLEMENTATIONS = {
    "network-baseline-aws": {"region": "eu-west-1", "mocks": "mock-aws.hcl"},
    "network-baseline-azure": {"region": "northeurope", "mocks": "mock-azure.hcl"},
}
SPECIFIC = {"network-baseline-aws": "aws.tftest.hcl", "network-baseline-azure": "azure.tftest.hcl"}
MARKER = "# --- mock block, kept in step with the contract by contract/render.py ---"


def expected() -> dict[pathlib.Path, str]:
    files = {}
    template = (CONTRACT / "contract.tftest.hcl.tmpl").read_text(encoding="utf-8")
    for name, settings in IMPLEMENTATIONS.items():
        module = ROOT / "modules" / name
        for shared in ("variables.tf", "outputs.tf", "locals.tf"):
            files[module / f"contract_{shared}"] = (CONTRACT / shared).read_text(encoding="utf-8")
        mocks = (CONTRACT / settings["mocks"]).read_text(encoding="utf-8").strip()
        files[module / "tests" / "contract.tftest.hcl"] = template.replace("@@MOCKS@@", mocks).replace("@@REGION@@", settings["region"])
        # The cloud-specific test file keeps its own tests and takes the same mock block.
        specific = module / "tests" / SPECIFIC[name]
        head, _, tail = specific.read_text(encoding="utf-8").partition(MARKER)
        rest = tail.split(MARKER, 1)[1] if MARKER in tail else tail
        files[specific] = f"{head}{MARKER}\n{mocks}\n{MARKER}{rest}"
    return files


def main() -> int:
    check = "--check" in sys.argv
    stale = []
    for path, content in expected().items():
        if check:
            if not path.exists() or path.read_text(encoding="utf-8") != content:
                stale.append(str(path.relative_to(ROOT)))
        else:
            path.parent.mkdir(parents=True, exist_ok=True)
            path.write_text(content, encoding="utf-8")
    if check:
        print("contract copies are current" if not stale else "STALE: " + ", ".join(stale))
        return 1 if stale else 0
    print(f"wrote {len(expected())} files")
    return 0


if __name__ == "__main__":
    sys.exit(main())

The copies are committed. A consumer who fetches one module gets a complete module, with no build step. CI runs render.py --check and fails if any copy differs from the source. Generated, committed, verified is a dull pattern and it is the right one here.

Contract tests: the same assertions for both clouds

The contract test file is a template. Everything below the mock block is identical for both implementations and reads only outputs, so it cannot depend on how either cloud builds the network. It follows the approach from last week’s article: plan runs for configured values, an apply run against the mock for computed ones, and expect_failures for the validation rules.

# GENERATED from contract/contract.tftest.hcl.tmpl by contract/render.py. Do not edit this copy.
# The same assertions run against every implementation of the contract. They read outputs only.

@@MOCKS@@

variables {
  name          = "records"
  environment   = "dev"
  region        = "@@REGION@@"
  address_space = "10.40.0.0/20"
}

run "subnets_are_named_and_sized_the_same_way" {
  command = plan

  assert {
    condition     = output.subnet_cidrs == { "private-1" = "10.40.0.0/22", "private-2" = "10.40.4.0/22" }
    error_message = "Two /22 subnets named private-1 and private-2 are expected from a /20."
  }
}

run "subnet_count_is_honoured" {
  command = plan

  variables {
    subnet_count = 4
  }

  assert {
    condition     = keys(output.subnet_cidrs) == ["private-1", "private-2", "private-3", "private-4"]
    error_message = "Four subnets are expected."
  }

  assert {
    condition     = output.subnet_cidrs["private-4"] == "10.40.12.0/22"
    error_message = "The fourth subnet must be the last quarter of the address space."
  }
}

run "tags_carry_workload_and_environment" {
  command = plan

  variables {
    tags = { cost-centre = "cc-1042" }
  }

  assert {
    condition     = output.tags == { "cost-centre" = "cc-1042", "environment" = "dev", "managed-by" = "terraform", "workload" = "records" }
    error_message = "Caller tags must be merged with the standard three."
  }
}

run "identifiers_are_exposed_after_apply" {
  command = apply

  assert {
    condition     = length(output.network_id) > 0 && length(output.log_store_id) > 0
    error_message = "network_id and log_store_id must be set."
  }

  assert {
    condition     = keys(output.subnet_ids) == keys(output.subnet_cidrs)
    error_message = "There must be one subnet id for each subnet CIDR, under the same names."
  }
}

run "rejects_an_address_space_that_is_too_small" {
  command = plan

  variables {
    address_space = "10.40.0.0/26"
  }

  expect_failures = [var.address_space]
}

run "rejects_text_that_is_not_a_cidr" {
  command = plan

  variables {
    address_space = "ten dot forty"
  }

  expect_failures = [var.address_space]
}

run "rejects_five_subnets" {
  command = plan

  variables {
    subnet_count = 5
  }

  expect_failures = [var.subnet_count]
}

run "rejects_short_log_retention" {
  command = plan

  variables {
    log_retention_days = 30
  }

  expect_failures = [var.log_retention_days]
}

run "rejects_a_name_with_a_hyphen" {
  command = plan

  variables {
    name = "patient-records"
  }

  expect_failures = [var.name]
}

Each module also has four tests of its own for the controls that only exist on its cloud. The Azure ones:

# What only the Azure implementation can be asked: the cloud-specific controls.

# --- mock block, kept in step with the contract by contract/render.py ---
# The Azure provider parses resource ids even when it is mocked. Give the mock well-formed ones
# for the two resources whose ids are passed to other resources.
mock_provider "azurerm" {
  mock_resource "azurerm_virtual_network" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Network/virtualNetworks/vnet-mock"
    }
  }

  mock_resource "azurerm_storage_account" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Storage/storageAccounts/stmock"
    }
  }
}
# --- mock block, kept in step with the contract by contract/render.py ---

variables {
  name          = "records"
  environment   = "dev"
  region        = "northeurope"
  address_space = "10.40.0.0/20"
}

run "subnets_have_no_default_outbound_access" {
  command = plan

  assert {
    condition     = alltrue([for subnet in azurerm_subnet.private : subnet.default_outbound_access_enabled == false])
    error_message = "Private subnets must not have default outbound access."
  }
}

run "log_storage_is_locked_down" {
  command = plan

  assert {
    condition     = azurerm_storage_account.logs.min_tls_version == "TLS1_2" && azurerm_storage_account.logs.https_traffic_only_enabled
    error_message = "The log storage account must require HTTPS with TLS 1.2 or later."
  }

  assert {
    condition     = azurerm_storage_account.logs.allow_nested_items_to_be_public == false && azurerm_storage_account.logs.shared_access_key_enabled == false
    error_message = "The log storage account must not allow public blobs or shared key access."
  }

  assert {
    condition     = azurerm_storage_account.logs.infrastructure_encryption_enabled
    error_message = "The log storage account must use infrastructure encryption."
  }
}

run "storage_account_name_fits_the_naming_rules" {
  command = plan

  variables {
    name = "longworkload"
  }

  assert {
    condition     = can(regex("^[a-z0-9]{3,24}$", azurerm_storage_account.logs.name))
    error_message = "Storage account names must be 3 to 24 lower-case letters and digits."
  }
}

run "logs_expire_after_the_retention_period" {
  command = plan

  assert {
    condition     = azurerm_storage_management_policy.logs.rule[0].actions[0].base_blob[0].delete_after_days_since_modification_greater_than == 365
    error_message = "Logs must be deleted after the retention period."
  }
}

Thirteen tests per module passed on Terraform 1.16.3 and on OpenTofu 1.12.6. To confirm the arrangement catches drift, the pipeline edits one copy of the contract, lowering the minimum log retention in the Azure module from 90 days to 30. Two things fail: render.py --check reports the file as stale, and the contract test rejects_short_log_retention fails in that module.

What mocked providers still check

Getting the apply runs to pass exposed something worth knowing. A mocked provider does not call the cloud, and it does still validate the format of some values. Left to generate placeholders, both providers refused them:

Error: "log_destination" (61cr1qfv) is an invalid ARN: arn: invalid prefix

Error: parsing "52yadty8": parsing the StorageAccount ID: the number of segments didn't match

The fix is to give the mock well-formed defaults for the few identifiers that are passed from one resource to another:

# The Azure provider parses resource ids even when it is mocked. Give the mock well-formed ones
# for the two resources whose ids are passed to other resources.
mock_provider "azurerm" {
  mock_resource "azurerm_virtual_network" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Network/virtualNetworks/vnet-mock"
    }
  }

  mock_resource "azurerm_storage_account" {
    defaults = {
      id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-mock/providers/Microsoft.Storage/storageAccounts/stmock"
    }
  }
}

The two tools behave differently here, and it matters for a shared suite. On Terraform 1.16.3 those errors appeared only in apply runs; plan runs passed, because a mocked computed value is unknown while planning. On OpenTofu 1.12.6 the same errors appeared in plan runs too, and its placeholders were four characters long where Terraform’s were eight. OpenTofu generates mock values during the plan. A suite that passes on one tool can fail on the other until the mocks have well-formed defaults, after which both agreed on all thirteen tests.

The switch module, measured

Here is the design this article argues against, written the way it is usually proposed:

# The pattern this article argues against for anything but small estates: one module with a switch.
# It is here so its costs can be measured beside the two-module design.

variable "cloud" {
  description = "Which cloud to build the baseline in."
  type        = string

  validation {
    condition     = contains(["aws", "azure"], var.cloud)
    error_message = "cloud must be aws or azure."
  }
}

variable "name" { type = string }
variable "environment" { type = string }
variable "region" { type = string }
variable "address_space" { type = string }

module "aws" {
  source = "../network-baseline-aws"
  count  = var.cloud == "aws" ? 1 : 0

  name          = var.name
  environment   = var.environment
  region        = var.region
  address_space = var.address_space
}

module "azure" {
  source = "../network-baseline-azure"
  count  = var.cloud == "azure" ? 1 : 0

  name          = var.name
  environment   = var.environment
  region        = var.region
  address_space = var.address_space
}

output "network_id" {
  value = one(concat(module.aws[*].network_id, module.azure[*].network_id))
}

output "subnet_cidrs" {
  value = one(concat(module.aws[*].subnet_cidrs, module.azure[*].subnet_cidrs))
}

It works. Its two tests, one per cloud, pass on both tools. The costs are in what it requires of every caller.

Bar chart of providers on disk after terraform init: 839 MB for the AWS module, 214 MB for the Azure module, and 1,053 MB for a facade module that switches between the two.
Providers on disk after terraform init. A caller of the facade carries both, whichever cloud it uses.
AWS moduleAzure moduleFacade
Providers installed by initawsazurermaws and azurerm
Providers on disk839 MB214 MB1,053 MB
init on a hosted runner5 s2 s6 s
Lock file entries to maintain112
A provider major-version upgrade affectsAWS consumersAzure consumersEvery consumer

The disk and time figures are from one run on one hosted runner, and the seconds are small. The structural points do not depend on the numbers. A team that only uses Azure installs a provider nearly four times the size of its own for a cloud it never touches. Both providers appear in its lock file and its upgrade backlog. And the switch buys the caller very little: the region value, the identifier formats and every operational limit are still cloud-specific, so the caller knows which cloud it is on anyway.

What to abstract and what to leave alone

Share across cloudsKeep cloud-specific
Input names, types and validation rulesResource types and their arguments
Output names and their meaningIdentifier formats
Naming conventions and mandatory tagsRegion names and data-residency decisions
Subnet arithmetic and address planningHow isolation, routing and outbound access work
Retention periods and classification rulesHow logs are captured and where they are stored
Contract testsTests of each cloud’s controls
The pipeline and the policy gateProvider versions and upgrade timing

The left column is policy and interface: things the organisation decides. The right column is mechanism: things the cloud decides. Abstractions over the left column last. Abstractions over the right column leak on first contact with a real requirement.

Alternatives considered

DesignAdvantagesDisadvantagesFits
One module with a cloud switchOne name to learn; one call siteBoth providers for every caller; lowest-common-denominator interface; coupled upgradesVery small estates, demos
One contract, a module per cloud (this article)Each cloud done properly; independent upgrades; drift is a build failureTwo implementations to maintain; the contract is copied, so it needs a checkPlatform teams serving several product teams
Separate modules, no shared contractLeast ceremonyInterfaces diverge quietly; consumers special-case each cloudA single team that owns both
A higher-level tool that generates TerraformReal shared code and typesAnother language and toolchain to ownOrganisations already invested in one
⚖️
SECURITY AND DATA PROTECTION

A baseline like this is where an organisation’s network and logging controls are decided once. The contract makes two of them explicit and testable on both clouds: network logs are retained for at least 90 days, and every resource carries workload and environment tags. Region is left to the caller on purpose, because where personal or clinical data is processed is a data protection decision that should be visible in the calling configuration and not buried in a mapping table. The tests here show the modules configure the intended controls. They do not show that a deployed network is isolated; that needs checks against the running environment.

Cost

Two implementations cost more to write than one, and that cost is paid once. What recurs is upgrade work, and here the two-module design is cheaper: each provider moves on its own schedule and only its consumers are affected. The contract adds one script and one CI step. Nothing in the test pipeline creates a resource, so there is no cloud spend in getting to a green build.

Decision framework

SituationRecommendation
One cloud today, a second is talked aboutWrite for the cloud you have. Keep inputs and outputs free of cloud-specific names where that costs nothing
Two clouds, one platform team, several consuming teamsOne contract, a module per cloud, contract tests on both
Two clouds, each owned by a different teamAgree the contract and the tests; each team owns its implementation
Pressure to “just add a cloud variable”Build the facade once in a branch and measure what callers inherit, as here
Modules must run on Terraform and OpenTofuRun the same tests on both in CI and give mocks well-formed identifiers

Key takeaways

  • Share the contract, not the resources: input names, validation, outputs, naming, tags and subnet arithmetic are identical in both modules, and nothing else is.
  • The same nine contract tests pass against the AWS and the Azure implementation, on Terraform 1.16.3 and OpenTofu 1.12.6, with no cloud account.
  • A copied contract needs a check. Weakening one rule in one copy failed the build twice: once as a stale file, once as a failed contract test.
  • Mocked providers still validate identifier formats, and the two tools generate mock values at different stages. Give mocks well-formed ARNs and resource ids.
  • A switch module made every caller install 1,053 MB of providers where the Azure module alone needs 214 MB, and coupled both clouds’ upgrades.

If you run one interface over two clouds and have drawn the line somewhere else, leave a comment and say where.

ℹ️
HOW THIS WAS CHECKED

All results are from GitHub Actions on ubuntu-latest with Terraform 1.16.3, OpenTofu 1.12.6, AWS provider 6.63.0 and AzureRM provider 5.2.0 (run 43297687751), with no cloud credentials. terraform validate accepted both modules against the real provider schemas. The listings are the files in the samples repository. Nothing was applied to an AWS account or an Azure subscription, so no claim is made that either cloud accepts these configurations at apply time. Disk sizes and init times are single measurements.

Glossary

  • Landing zone— The baseline of accounts or subscriptions, networking, identity and logging that workloads are deployed into.
  • Contract— The inputs, outputs and behaviour a module promises to its callers, independent of how it is implemented.
  • Contract test— A test that checks an implementation against the contract using only what a caller can see.
  • Facade module— A module that wraps several implementations behind one interface with a switch.
  • Default outbound access— On Azure, the implicit internet egress a subnet has unless it is turned off.
  • Flow log— A record of network traffic accepted and rejected in a virtual network.
  • Diagnostic setting— The Azure resource that routes a resource’s logs and metrics to a destination.
  • Drift— A difference between copies, or between configuration and reality, that nobody intended.

References & Further Reading


Discover more from C4: Container, Code, Cloud & Context

Subscribe to get the latest posts sent to your email.

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.