Python Azure Storage Redundancy Census — the copy that is not a backup
Three copies in three buildings will faithfully replicate your delete.
<!-- hal:authoritative:yaml -->
Three copies in three buildings will faithfully replicate your delete.
§I — Frame
Yesterday named a path. An Ingress rule matched a host and a prefix and pointed at a Service; the rule was not the Service. Coin of that day: the path that is not a Service. The cloud seat was AKS because the Cert seat was CKA.
The day before named a replace. A Key Vault secret version changed, and Terraform replaced a resource that had no diff of its own. Coin: the replace that is not a diff. Azure App Service was the referent because the TF track chose it.
Today the Cloud rotation counter reads 7. Seven mod 4 is 3. Seat 3 is AZ-900, the third Microsoft visit. 08-11 took the hierarchy and the governance spine. 08-23 took Entra ID and the credential that is not a key. The AZ-900 storage bullets are still on the table: compare the services, describe the tiers, describe the redundancy options. Today's Ops lesson takes the redundancy half and the one question the exam and the on-call both ask in different clothes. If a storage account is Standard_GRS, is the data backed up?
No. Geo-redundant storage keeps six copies across two regions and every one of them will honor a DELETE. Redundancy is a promise about hardware and regions. Backup is a promise about your own mistakes. The properties that make the second promise sit on a different object: the blob service's delete_retention_policy, container_delete_retention_policy, is_versioning_enabled, and restore_policy. An account can carry sixteen nines of durability on the SKU and zero days of soft delete on the service.
Coin it: the copy that is not a backup.
The tool today is a census. It lists every storage account the credential can see, prints the redundancy SKU beside the protection flags, and names the accounts where the geo-copy is paid for and the undo is off. It changes nothing.
§II — Foundations: four facts about the copy
Fact one. The SKU is the redundancy. sku.name on a storage account is one of Standard_LRS, Standard_ZRS, Standard_GRS, Standard_GZRS, Standard_RAGRS, Standard_RAGZRS, or the premium variants. LRS is three synchronous copies in one datacenter. ZRS is three synchronous copies across three availability zones in the primary region. GRS is LRS in the primary plus an asynchronous LRS copy in the paired region. GZRS is ZRS in the primary plus LRS in the secondary. The RA prefix means the secondary is readable at the -secondary endpoint; without it, the secondary exists and you cannot read it until a failover. Every one of these replicates writes, and a delete is a write.
Fact two. Data protection is a blob-service property, not an account property. blob_services.get_service_properties(rg, account) returns the object that carries delete_retention_policy (blob soft delete, 1 to 365 days), container_delete_retention_policy (container soft delete), is_versioning_enabled, change_feed, and restore_policy (point-in-time restore, which requires versioning, change feed, and blob soft delete together). None of these are on the StorageAccount model. A census that reads only storage_accounts.list() cannot see whether a delete is recoverable. It has to make the second call.
Fact three. The access tier is a third axis and it interacts. access_tier on the account is the default online tier for blobs that do not set their own: Hot, Cool, or Cold. Archive is blob-level only and offline; reading an archived blob means rehydrating it, up to fifteen hours. Archive is only supported on LRS, GRS, and RA-GRS accounts, not ZRS or GZRS. A census that reports Standard_ZRS with archived blobs is reporting something that cannot exist; a census that reports Standard_GRS plus archive plus no soft delete is reporting a blob that takes fifteen hours to read and zero seconds to lose.
Fact four. Region pairs are not your failover button. secondary_location and status_of_secondary tell you the paired region and whether replication is available. Failover for GRS and GZRS is customer-initiated for an outage you declare, or Microsoft-managed for a region Microsoft loses. Neither restores a blob you deleted last Tuesday. The undo for that is soft delete or a version, and both are on the service object from fact two.
§III — Mechanism: the census, not the remediation
The tool reads the management plane only. azure-identity gives DefaultAzureCredential, which 08-23 already took apart; today reuses it and does not reteach it. azure-mgmt-storage gives StorageManagementClient. Two calls per account: the account list gives SKU, tier, and secondary; the blob service properties give the protection flags.
from dataclasses import dataclass
from azure.identity import DefaultAzureCredential
from azure.mgmt.storage import StorageManagementClient
@dataclass(frozen=True)
class AccountRow:
name: str
resource_group: str
sku: str
access_tier: str | None
location: str
secondary: str | None
secondary_status: str | None
blob_soft_delete_days: int | None
container_soft_delete_days: int | None
versioning: bool
restore_days: int | None
def resource_group_of(resource_id: str) -> str:
return resource_id.split("/")[4]
def retention_days(policy) -> int | None:
if policy is None or not policy.enabled:
return None
return policy.days
def census(subscription_id: str) -> list[AccountRow]:
client = StorageManagementClient(DefaultAzureCredential(), subscription_id)
rows = []
for acct in client.storage_accounts.list():
rg = resource_group_of(acct.id)
props = client.blob_services.get_service_properties(rg, acct.name)
rows.append(AccountRow(
name=acct.name,
resource_group=rg,
sku=acct.sku.name,
access_tier=acct.access_tier,
location=acct.location,
secondary=acct.secondary_location,
secondary_status=acct.status_of_secondary,
blob_soft_delete_days=retention_days(props.delete_retention_policy),
container_soft_delete_days=retention_days(props.container_delete_retention_policy),
versioning=bool(props.is_versioning_enabled),
restore_days=retention_days(props.restore_policy),
))
return rows
The dataclass is frozen. That is deliberate and it is today's Dev lesson in one keyword: a census row is a snapshot, and a snapshot that can be mutated after capture is a shared reference to a lie. resource_group_of slices the ARM resource ID at index 4 because the shape is fixed: /subscriptions/{sub}/resourceGroups/{rg}/providers/.... Index 4 is the group. retention_days collapses the three states a policy can be in (absent, present-disabled, present-enabled) into one integer or None, so the finding logic downstream compares against None and never against a nested object.
get_service_properties is a GET. storage_accounts.list is a GET. There is no create_or_update, no set_service_properties, no undelete_blob in this file. The census names the finding; a human decides whether to turn on soft delete and how many days. That boundary is the same one 09-01 drew for dry_run: the tool reports the number, it does not rewrite the SQL.
The finding
GEO = {"Standard_GRS", "Standard_GZRS", "Standard_RAGRS", "Standard_RAGZRS"}
def unprotected_geo(rows: list[AccountRow]) -> list[AccountRow]:
return [
r for r in rows
if r.sku in GEO
and r.blob_soft_delete_days is None
and not r.versioning
]
def print_census(rows: list[AccountRow]) -> None:
header = f"{'account':<26}{'sku':<18}{'tier':<6}{'secondary':<16}{'softdel':<9}{'ctrdel':<8}{'vers':<6}{'restore'}"
print(header)
for r in rows:
print(
f"{r.name:<26}{r.sku:<18}{(r.access_tier or '-'):<6}"
f"{(r.secondary or '-'):<16}"
f"{str(r.blob_soft_delete_days or '-'):<9}"
f"{str(r.container_soft_delete_days or '-'):<8}"
f"{('yes' if r.versioning else 'no'):<6}"
f"{r.restore_days or '-'}"
)
unprotected_geo is the coin as a list comprehension. It is a listcomp and not a genexp because the caller will print it, count it, and possibly write it to a ticket; 09-01 said square brackets when you need the list twice. The predicate is three clauses. The SKU says a second region is being paid for. The soft-delete days say a deleted blob is gone at the moment of delete. The versioning flag says an overwritten blob is gone at the moment of overwrite. An account matching all three has the expensive copy and no backup.
The optional data-plane pass
The management plane cannot tell you what tier individual blobs are in, and it cannot tell you whether any are archived. That is a data-plane question against each container, and it needs a different package and a different permission (Storage Blob Data Reader, not Reader). The census makes it optional and read-only.
from collections import Counter
from azure.storage.blob import BlobServiceClient
def tier_counts(account_name: str, credential) -> Counter:
svc = BlobServiceClient(
f"https://{account_name}.blob.core.windows.net",
credential=credential,
)
counts: Counter = Counter()
for container in svc.list_containers():
cc = svc.get_container_client(container.name)
for blob in cc.list_blobs():
counts[blob.blob_tier or "inferred"] += 1
if blob.archive_status:
counts[f"rehydrating:{blob.archive_status}"] += 1
return counts
blob.blob_tier is Hot, Cool, Cold, or Archive, or None when the blob inherits the account default; the tool labels the inherited case inferred because that is what the portal prints. archive_status is set only while a rehydration is pending. list_blobs pages under the hood; the for is honest about walking every blob, and on a large account that is the scan that is not the table again. Run it on the accounts unprotected_geo named, not on the whole subscription.
The entry point and the exit code
import argparse
import sys
def main() -> int:
parser = argparse.ArgumentParser(description="Azure Storage redundancy vs protection census")
parser.add_argument("--subscription", required=True)
parser.add_argument("--fail-on-findings", action="store_true")
args = parser.parse_args()
rows = census(args.subscription)
print_census(rows)
findings = unprotected_geo(rows)
for r in findings:
print(f"FINDING {r.resource_group}/{r.name}: {r.sku} with no blob soft delete and no versioning")
if findings and args.fail_on_findings:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Two permissions, both read-only. The management-plane calls need the built-in Reader role on the subscription or on each resource group; Reader covers Microsoft.Storage/storageAccounts/read and blobServices/read. The optional data-plane pass needs Storage Blob Data Reader on each account it walks, because listing blobs is a data operation that Reader does not grant. 08-23 covered why the credential that satisfies both is not a key in a file.
--fail-on-findings is for the pipeline. A nightly job that exits 1 when a geo-redundant account has no soft delete turns the census into a gate without turning it into a remediation. The gate still changes nothing in Azure. It refuses to go green until a human does.
§IV — Worked example: one subscription, three accounts, one finding
Suppose the census returns three rows.
account sku tier secondary softdel ctrdel vers restore
prodlogs01 Standard_GRS Hot westus - - no -
prodmedia01 Standard_RAGZRS Cool westus 14 7 yes 7
devscratch01 Standard_LRS Hot - 7 - no -
prodlogs01 is the finding. Geo copy on, six copies across two regions, and a delete anywhere is a delete everywhere with no retention. The account's durability is sixteen nines; its recoverability from an operator az storage blob delete-batch is zero. It will appear in unprotected_geo.
prodmedia01 is the shape you want. RA-GZRS means three zones in the primary, an LRS copy in the paired region, and a readable secondary endpoint. Fourteen days of blob soft delete, seven days of container soft delete, versioning on, point-in-time restore for seven days. It is Cool by default, which is fine for media that is read less than it is written; the census does not judge the tier, it prints it next to the rest.
devscratch01 is LRS with seven days of soft delete and no geo copy. It will not appear in unprotected_geo because the SKU is not in GEO. That is correct. The finding is not "this account lacks a backup." The finding is "this account bought a copy and mistook it for one." Dev scratch with soft delete is more honest than prod logs with GRS.
The remediation is one property on one object, delete_retention_policy with enabled true and days set. The census does not do it. The ticket does.
§IV.B — Five leftovers the census is for
Leftover one. An account converted from LRS to GRS last quarter for a compliance line item. Nobody touched the blob service. The SKU changed; the protection did not.
Leftover two. A landing-zone Bicep module that sets sku.name from a parameter and never declares Microsoft.Storage/storageAccounts/blobServices. Every account it stamps out is unprotected_geo on day one.
Leftover three. Archive blobs on an account someone wants to move to ZRS. The move requires rehydrating every archived blob first. The data-plane pass finds them before the change request does.
Leftover four. A status_of_secondary that is not available. Replication is lagging or the pair is degraded. Still not a backup, but now also not a copy.
Leftover five. Versioning on, soft delete off. Overwrites are recoverable; deletes of the current version are recoverable through versions, but a delete of all versions is not. The predicate treats versioning as protection because it is; the row shows the soft-delete dash so a reader can see the second belt is missing.
§V — Connection to prior lessons
09-01 built a census that printed num_bytes beside total_bytes_processed and let the reader see that a WHERE had not pruned. Today's census prints sku beside delete_retention_policy and lets the reader see that a copy has not protected. Same shape: two numbers from two calls on one line, and the finding is the gap between them.
08-23 built the credential. DefaultAzureCredential walks environment, workload identity, managed identity, CLI, and the rest, and the lesson said the credential is not a key. Today imports it in one line and spends no words on it. That is what within-track reach is for: the prior lesson holds the weight so this one does not have to.
08-11 ran fan-out across Azure Resource Manager under an asyncio.Semaphore. Today's census is sequential on purpose. A subscription with forty storage accounts is eighty GETs and a few seconds; if the count is four hundred, 08-11's semaphore wraps get_service_properties and the row shape does not change.
§VI — Connection to today's Dev lesson
The Dev lesson names the same coin without a cloud. snapshot = dict(config) is a copy of the outer dict and a shared reference to every nested dict inside it. Mutate config["tags"]["env"] and the snapshot changes too. That is a shallow copy, and Ramalho's Chapter 6 says copies are shallow by default. GRS is a shallow copy at region scale: the outer promise is duplicated, and the inner object, your data as it is right now, is shared across every replica including its deletion. copy.deepcopy is the language's soft delete: a real second object that does not move when the first one does.
The Dev lesson also answers why AccountRow is frozen. A census row that can be mutated after the fact is bus2.passengers. A frozen row is bus3.
§VII — Closing
Read the SKU. Then read the blob service. If the SKU has G in it and delete_retention_policy is None, you have paid for a second region to hold the same mistake. Turn on soft delete, turn on versioning, and let the census go quiet on that row.
Ramalho: copies are shallow by default. Azure: redundancy is not backup. The census sentence is shorter. The copy is not a backup.
Examine the next storage account someone calls "geo-redundant, so we're covered." Ask for the retention days. If the answer is a dash, the coin is already spent.
Related
- Prior arc: the scan that is not the table (2026-09-01)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding: Ramalho — Copies Are Shallow by Default (Ch.6, pp. 208-211) · AZ-900 clone — Access Tiers