Skip to content

Commit c669fdc

Browse files
committed
feat(terraform): add the Azure storage module
Mirrors deployment/terraform/modules/aws/s3. Backs the Onyx file store, which already speaks Azure Blob through backend/onyx/file_store/azure_blob_file_store.py. The outputs line up with the environment the app reads: storage_account_name, primary_blob_endpoint and container_name feed AZURE_STORAGE_ACCOUNT_NAME, AZURE_STORAGE_ACCOUNT_URL and AZURE_FILE_STORE_CONTAINER_NAME. Three places where Azure does not map cleanly onto the S3 module: - Shared access keys are off by default. The app authenticates with DefaultAzureCredential, so a key never has to exist, and the account defaults to Entra ID authentication. - Network rules replace the bucket policy. With no allowlist the account stays reachable and is gated on Entra ID alone, which is how the s3 module behaves with no policy attached. Supplying subnets or IPs flips it to deny-first. - Cool tiering waits 30 days rather than the s3 module's 7. Azure has no Intelligent-Tiering, and Cool bills a 30-day minimum per blob, so moving earlier costs more than it saves.
1 parent 7665c83 commit c669fdc

5 files changed

Lines changed: 493 additions & 0 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
locals {
2+
# With no allowlist the account stays reachable from any network and access
3+
# is gated on Entra ID alone, which is how the AWS s3 module behaves when no
4+
# bucket policy is set. Supplying either list flips the account to deny-first.
5+
restrict_network = length(var.allowed_subnet_ids) > 0 || length(var.allowed_source_ips) > 0
6+
7+
# A management policy with no rules is rejected, so only create one when at
8+
# least one rule applies.
9+
has_lifecycle_rules = var.enable_versioning || var.expiration_days > 0 || var.transition_to_cool
10+
}
11+
12+
resource "azurerm_storage_account" "this" {
13+
name = var.storage_account_name
14+
resource_group_name = var.resource_group_name
15+
location = var.location
16+
17+
account_kind = "StorageV2"
18+
account_tier = var.account_tier
19+
account_replication_type = var.account_replication_type
20+
21+
https_traffic_only_enabled = true
22+
min_tls_version = var.min_tls_version
23+
public_network_access_enabled = var.public_network_access_enabled
24+
shared_access_key_enabled = var.shared_access_key_enabled
25+
26+
# The pair of settings that keep blobs from ever being served anonymously.
27+
allow_nested_items_to_be_public = false
28+
default_to_oauth_authentication = true
29+
30+
tags = var.tags
31+
32+
blob_properties {
33+
versioning_enabled = var.enable_versioning
34+
35+
dynamic "delete_retention_policy" {
36+
for_each = var.blob_soft_delete_days > 0 ? [1] : []
37+
content {
38+
days = var.blob_soft_delete_days
39+
}
40+
}
41+
42+
dynamic "container_delete_retention_policy" {
43+
for_each = var.container_soft_delete_days > 0 ? [1] : []
44+
content {
45+
days = var.container_soft_delete_days
46+
}
47+
}
48+
}
49+
50+
dynamic "network_rules" {
51+
for_each = local.restrict_network ? [1] : []
52+
content {
53+
default_action = "Deny"
54+
bypass = var.network_rules_bypass
55+
virtual_network_subnet_ids = var.allowed_subnet_ids
56+
ip_rules = var.allowed_source_ips
57+
}
58+
}
59+
}
60+
61+
resource "azurerm_storage_container" "this" {
62+
name = var.container_name
63+
storage_account_id = azurerm_storage_account.this.id
64+
container_access_type = "private"
65+
}
66+
67+
resource "azurerm_storage_management_policy" "this" {
68+
count = local.has_lifecycle_rules ? 1 : 0
69+
storage_account_id = azurerm_storage_account.this.id
70+
71+
dynamic "rule" {
72+
for_each = var.enable_versioning ? [1] : []
73+
content {
74+
name = "noncurrent-version-expiration"
75+
enabled = true
76+
77+
filters {
78+
blob_types = ["blockBlob"]
79+
}
80+
81+
actions {
82+
version {
83+
delete_after_days_since_creation = var.noncurrent_expiration_days
84+
}
85+
}
86+
}
87+
}
88+
89+
dynamic "rule" {
90+
for_each = var.expiration_days > 0 ? [1] : []
91+
content {
92+
name = "object-expiration"
93+
enabled = true
94+
95+
filters {
96+
blob_types = ["blockBlob"]
97+
}
98+
99+
actions {
100+
base_blob {
101+
delete_after_days_since_modification_greater_than = var.expiration_days
102+
}
103+
}
104+
}
105+
}
106+
107+
dynamic "rule" {
108+
for_each = var.transition_to_cool ? [1] : []
109+
content {
110+
name = "transition-to-cool"
111+
enabled = true
112+
113+
filters {
114+
blob_types = ["blockBlob"]
115+
}
116+
117+
actions {
118+
base_blob {
119+
tier_to_cool_after_days_since_modification_greater_than = var.transition_to_cool_days
120+
}
121+
}
122+
}
123+
}
124+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
output "storage_account_id" {
2+
description = "Resource ID of the storage account. Role assignments and flow log destinations take this."
3+
value = azurerm_storage_account.this.id
4+
}
5+
6+
output "storage_account_name" {
7+
description = "Name of the storage account. Set this as AZURE_STORAGE_ACCOUNT_NAME."
8+
value = azurerm_storage_account.this.name
9+
}
10+
11+
output "primary_blob_endpoint" {
12+
description = "Blob service endpoint. Set this as AZURE_STORAGE_ACCOUNT_URL."
13+
value = azurerm_storage_account.this.primary_blob_endpoint
14+
}
15+
16+
output "container_name" {
17+
description = "Name of the file store container. Set this as AZURE_FILE_STORE_CONTAINER_NAME."
18+
value = azurerm_storage_container.this.name
19+
}
20+
21+
output "primary_access_key" {
22+
description = "Shared access key, null unless shared_access_key_enabled is true. Onyx uses workload identity and does not need it."
23+
value = var.shared_access_key_enabled ? azurerm_storage_account.this.primary_access_key : null
24+
sensitive = true
25+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Plans the module against a mocked provider, so these run without an Azure
2+
# subscription or credentials. Run with `terraform test` from the module directory.
3+
4+
mock_provider "azurerm" {}
5+
6+
variables {
7+
storage_account_name = "onyxfilestoreprod"
8+
resource_group_name = "onyx-rg"
9+
location = "eastus"
10+
}
11+
12+
run "defaults_are_private_and_keyless" {
13+
command = plan
14+
15+
assert {
16+
condition = azurerm_storage_account.this.shared_access_key_enabled == false
17+
error_message = "Onyx authenticates with workload identity, so shared keys should be off by default."
18+
}
19+
20+
assert {
21+
condition = azurerm_storage_account.this.allow_nested_items_to_be_public == false
22+
error_message = "Blobs must never be servable anonymously."
23+
}
24+
25+
assert {
26+
condition = azurerm_storage_account.this.default_to_oauth_authentication == true
27+
error_message = "The account should default to Entra ID authentication."
28+
}
29+
30+
assert {
31+
condition = azurerm_storage_container.this.container_access_type == "private"
32+
error_message = "The file store container must be private."
33+
}
34+
35+
assert {
36+
condition = azurerm_storage_account.this.min_tls_version == "TLS1_2"
37+
error_message = "The account should refuse TLS below 1.2."
38+
}
39+
}
40+
41+
run "no_allowlist_leaves_the_account_open_to_the_network" {
42+
command = plan
43+
44+
assert {
45+
condition = length(azurerm_storage_account.this.network_rules) == 0
46+
error_message = "With no allowlist the account is gated on Entra ID alone, matching how the AWS s3 module behaves with no bucket policy."
47+
}
48+
}
49+
50+
run "an_allowlist_flips_the_account_to_deny_first" {
51+
command = plan
52+
53+
variables {
54+
allowed_subnet_ids = ["/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/onyx-rg/providers/Microsoft.Network/virtualNetworks/onyx-vnet/subnets/onyx-aks"]
55+
}
56+
57+
assert {
58+
condition = one(azurerm_storage_account.this.network_rules).default_action == "Deny"
59+
error_message = "Supplying an allowlist must deny everything else."
60+
}
61+
62+
assert {
63+
condition = contains(one(azurerm_storage_account.this.network_rules).bypass, "AzureServices")
64+
error_message = "AzureServices must stay exempt or Monitor and Backup lose access."
65+
}
66+
}
67+
68+
run "default_lifecycle_rules" {
69+
command = plan
70+
71+
assert {
72+
condition = length(azurerm_storage_management_policy.this[0].rule) == 2
73+
error_message = "Versioning and cool-tiering are on by default; blob expiry is not."
74+
}
75+
76+
assert {
77+
condition = contains(azurerm_storage_management_policy.this[0].rule[*].name, "noncurrent-version-expiration")
78+
error_message = "Non-current versions should expire by default."
79+
}
80+
81+
assert {
82+
condition = contains(azurerm_storage_management_policy.this[0].rule[*].name, "transition-to-cool")
83+
error_message = "Blobs should tier to Cool by default."
84+
}
85+
}
86+
87+
run "expiry_adds_a_third_rule" {
88+
command = plan
89+
90+
variables {
91+
expiration_days = 365
92+
}
93+
94+
assert {
95+
condition = length(azurerm_storage_management_policy.this[0].rule) == 3
96+
error_message = "Setting expiration_days should add the object-expiration rule."
97+
}
98+
}
99+
100+
run "no_rules_means_no_policy" {
101+
command = plan
102+
103+
variables {
104+
enable_versioning = false
105+
transition_to_cool = false
106+
expiration_days = 0
107+
}
108+
109+
assert {
110+
condition = length(azurerm_storage_management_policy.this) == 0
111+
error_message = "Azure rejects a management policy with no rules, so the module must not create one."
112+
}
113+
}
114+
115+
run "rejects_a_name_azure_would_reject" {
116+
command = plan
117+
118+
variables {
119+
storage_account_name = "Onyx-File-Store"
120+
}
121+
122+
expect_failures = [var.storage_account_name]
123+
}
124+
125+
run "rejects_a_name_that_is_too_long" {
126+
command = plan
127+
128+
variables {
129+
storage_account_name = "onyxfilestoreproductioneastus"
130+
}
131+
132+
expect_failures = [var.storage_account_name]
133+
}
134+
135+
run "rejects_a_host_prefix_azure_would_reject" {
136+
command = plan
137+
138+
variables {
139+
allowed_source_ips = ["203.0.113.7/32"]
140+
}
141+
142+
expect_failures = [var.allowed_source_ips]
143+
}
144+
145+
run "rejects_an_invalid_bypass" {
146+
command = plan
147+
148+
variables {
149+
network_rules_bypass = ["AzureServices", "Everything"]
150+
}
151+
152+
expect_failures = [var.network_rules_bypass]
153+
}

0 commit comments

Comments
 (0)