Hedronite · Ops Lesson · 01-Earth-DevOps · Sat 2026-09-26

Terraform AWS ECR registry scanning and a Rust census of the scan that actually applies

The repository says scan on push. The registry decides. Declare the registry rule, then ask AWS which rule each repository got.

Lesson Class: Ops (T2 · Terraform + Rust ops automation)
Cloud Referent: AWS ECR · registry scanning · IMMUTABLE tags · lifecycle policy
Automation: cargo script + aws-sdk-ecr 1.131.0 + Nix writeBashBin
Checked: terraform validate · cargo check · nix-build (census not run: no nightly, no creds)
Paired Dev: Rust serde over plan JSON
Paired Cert: Type constraints, optional(), nullable
Scan Owner
Ask which resource owns a behavior before asking its value.
Effective Read
Read the resolved scan frequency, never the legacy repository flag.
A green plan can describe a posture AWS does not enforce.

<!-- hal:authoritative:yaml -->

The repository says scan on push. The registry decides whether anything scans. Declare the registry rule in Terraform, then ask AWS which rule each repository actually got.

§I. Frame

Amazon ECR used to take its scan setting from each repository: scanOnPush = true on the repository, and pushes got a basic scan. AWS moved that decision up one level. The containers blog states it plainly: the repository-level scan-on-push feature "has now been deprecated in favor of registry-level scan configurations." A registry has one scanning configuration per region, with a scan type (BASIC or ENHANCED) and rules that match repositories by name filter.

The Terraform provider did not remove the old block. Against hashicorp/aws v6.66.0 on the lab Mac, terraform providers schema -json still lists image_scanning_configuration { scan_on_push = bool } on aws_ecr_repository, marked required inside the block and carrying no deprecation flag. So a module can set the old flag, pass validate, and still leave a repository at MANUAL.

The problem for today: declare ECR the current way in Terraform, then build a read-only Rust census that reports the scan frequency each repository actually has, along with tag mutability and lifecycle coverage.

§II. Three settings, three owners

Scan Owner (named technique). Ask which resource owns a behavior before asking what value it has. For ECR in 2026 the answer splits three ways:

  1. Scanning belongs to the registry: aws_ecr_registry_scanning_configuration. With BASIC, repositories that match a SCAN_ON_PUSH rule scan on push. Every other repository falls to MANUAL, per the ECR filter docs.
  2. Tag mutability belongs to the repository: image_tag_mutability = "IMMUTABLE" makes a pushed tag permanent, so v1.4.2 cannot be silently repointed.
  3. Retention belongs to a separate resource: aws_ecr_lifecycle_policy, a JSON rule list attached by repository name.

If you mix up the owners, a green plan will describe a posture AWS does not enforce.

§III. The Terraform

This file passed terraform fmt -check and terraform validate on the lab Mac (Terraform 1.14.3, hashicorp/aws v6.66.0, init -backend=false). It was not planned or applied, because no AWS credentials were used this fire.

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_kms_key" "ecr" {
  description         = "ECR image encryption"
  enable_key_rotation = true
}

# Registry-wide scanning: the only scan setting AWS still honors.
resource "aws_ecr_registry_scanning_configuration" "this" {
  scan_type = "BASIC"

  rule {
    scan_frequency = "SCAN_ON_PUSH"
    repository_filter {
      filter      = "prod-*"
      filter_type = "WILDCARD"
    }
  }
}

resource "aws_ecr_repository" "api" {
  name                 = "prod-api"
  image_tag_mutability = "IMMUTABLE"

  encryption_configuration {
    encryption_type = "KMS"
    kms_key         = aws_kms_key.ecr.arn
  }
}

resource "aws_ecr_lifecycle_policy" "api" {
  repository = aws_ecr_repository.api.name
  policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "expire untagged after 14 days"
        selection = {
          tagStatus   = "untagged"
          countType   = "sinceImagePushed"
          countUnit   = "days"
          countNumber = 14
        }
        action = { type = "expire" }
      },
      {
        rulePriority = 2
        description  = "keep last 50 release images"
        selection = {
          tagStatus      = "tagged"
          tagPatternList = ["v*"]
          countType      = "imageCountMoreThan"
          countNumber    = 50
        }
        action = { type = "expire" }
      }
    ]
  })
}

Four decisions are visible here:

§IV. The census, in one cargo script

ecr-scan-census.rs sits in this bundle. It is a single file with its Cargo manifest in frontmatter, run with cargo +nightly -Zscript:

#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"

[dependencies]
aws-config = { version = "1", features = ["behavior-version-latest"] }
aws-sdk-ecr = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
---

use aws_config::BehaviorVersion;
use aws_sdk_ecr::{Client, Error};

#[derive(Debug)]
struct RepoRow {
    name: String,
    mutability: String,
    frequency: String,
    filters: Vec<String>,
    lifecycle: bool,
}

async fn repo_names(client: &Client) -> Result<Vec<(String, String)>, Error> {
    let mut out = Vec::new();
    let mut repos = client.describe_repositories().into_paginator().items().send();
    while let Some(repo) = repos.next().await {
        let repo = repo?;
        let name = repo.repository_name().unwrap_or("?").to_string();
        let mutability = repo.image_tag_mutability().map_or("?", |m| m.as_str()).to_string();
        out.push((name, mutability));
    }
    Ok(out)
}

async fn has_lifecycle(client: &Client, name: &str) -> Result<bool, Error> {
    match client.get_lifecycle_policy().repository_name(name).send().await {
        Ok(_) => Ok(true),
        Err(e) if e.as_service_error().is_some_and(|s| s.is_lifecycle_policy_not_found_exception()) => Ok(false),
        Err(e) => Err(e.into()),
    }
}

async fn census(client: &Client) -> Result<Vec<RepoRow>, Error> {
    let repos = repo_names(client).await?;
    let mut rows = Vec::new();
    // BatchGetRepositoryScanningConfiguration takes at most 25 names per call.
    for batch in repos.chunks(25) {
        let names: Vec<String> = batch.iter().map(|(n, _)| n.clone()).collect();
        let resp = client
            .batch_get_repository_scanning_configuration()
            .set_repository_names(Some(names))
            .send()
            .await?;
        for cfg in resp.scanning_configurations() {
            let name = cfg.repository_name().unwrap_or("?").to_string();
            let mutability = batch
                .iter()
                .find(|(n, _)| *n == name)
                .map_or("?".to_string(), |(_, m)| m.clone());
            rows.push(RepoRow {
                lifecycle: has_lifecycle(client, &name).await?,
                frequency: cfg.scan_frequency().map_or("?", |f| f.as_str()).to_string(),
                filters: cfg
                    .applied_scan_filters()
                    .iter()
                    .map(|f| f.filter().to_string())
                    .collect(),
                name,
                mutability,
            });
        }
    }
    Ok(rows)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    let config = aws_config::defaults(BehaviorVersion::latest()).load().await;
    let client = Client::new(&config);
    let registry = client.get_registry_scanning_configuration().send().await?;
    let scan_type = registry
        .scanning_configuration()
        .and_then(|c| c.scan_type())
        .map_or("BASIC (default)", |t| t.as_str());
    let rows = census(&client).await?;
    println!("registry scan_type={scan_type} repositories={}", rows.len());
    for r in &rows {
        println!(
            "{} mutability={} scan={} filters=[{}] lifecycle={}",
            r.name, r.mutability, r.frequency, r.filters.join(","), r.lifecycle
        );
    }
    for r in rows.iter().filter(|r| r.frequency == "MANUAL" || r.mutability == "MUTABLE" || !r.lifecycle) {
        println!("attention {}", r.name);
    }
    Ok(())
}

Effective Read (named technique). The census never reads imageScanningConfiguration from describe_repositories. That field reports the old per-repository flag. The answer comes from batch_get_repository_scanning_configuration, which returns each repository's resolved scan_frequency and the applied_scan_filters that produced it. If no rule matched, applied_scan_filters is empty and the frequency is MANUAL. That empty list is the finding.

Three mechanics carry over from the StackSet census (09-25):

  1. into_paginator().items() walks every page of describe_repositories, so a registry with more repositories than one page holds still gets a full census.
  2. repos.chunks(25) respects the batch API's 25-name ceiling. chunks is a slice method that yields non-overlapping windows, the same iterator family TRPL ch13 teaches.
  3. get_lifecycle_policy treats a missing policy as an error. The match arm turns LifecyclePolicyNotFoundException into Ok(false), and every other error still stops the run.

What was checked, and what was not. A scratch bin crate built from the frontmatter manifest and body passed cargo check on stable cargo 1.96.0 with no warnings, resolving aws-sdk-ecr 1.131.0, aws-config 1.12.0 and tokio 1.53.1. The -Zscript entry needs nightly, which the lab Mac does not have. The script was not run against AWS. Output from a registry where one repository misses the prod-* filter would read like this (illustrative):

registry scan_type=BASIC repositories=2
prod-api mutability=IMMUTABLE scan=SCAN_ON_PUSH filters=[prod-*] lifecycle=true
tools-builder mutability=MUTABLE scan=MANUAL filters=[] lifecycle=false
attention tools-builder

§V. Wrap it with Nix

ecr-scan-census.nix gives the script a stable command name:

{ pkgs ? import <nixpkgs> { } }:
pkgs.writers.writeBashBin "ecr-scan-census" ''
  exec cargo +nightly -Zscript ${./ecr-scan-census.rs} "$@"
''

Built on the lab Mac against the same pinned nixpkgs as this week's weekend lesson (rev d54020a6), it produced /nix/store/hqlybr7q…-ecr-scan-census. The generated bin/ecr-scan-census is two lines: a store-path bash shebang and exec cargo +nightly -Zscript /nix/store/2rmi1m0d…-ecr-scan-census.rs "$@". The script itself was copied into the store, so the wrapper runs the version it was built from even after the vault copy changes. The wrapper does not pin cargo or nightly. Whoever runs it still needs a nightly toolchain on PATH, and that gap is the next thing to close.

§VI. What not to do

  1. Setting image_scanning_configuration { scan_on_push = true } and calling the repository covered. It validates and it plans, and it is not the control AWS reads.
  2. Declaring aws_ecr_registry_scanning_configuration in more than one root module per region.
  3. Reading scan posture from describe_repositories in automation.
  4. Leaving MUTABLE on release repositories and then trusting a tag in a deploy manifest.
  5. Giving the census write permissions. It needs ecr:DescribeRepositories, ecr:BatchGetRepositoryScanningConfiguration, ecr:GetLifecyclePolicy and ecr:GetRegistryScanningConfiguration, and nothing else.

§VII. Close instruction

Write the four-action read-only IAM policy for the census in HCL. Then add a second rule to the registry configuration so a tools-* repository scans on push, and predict what applied_scan_filters will return for tools-builder once it applies.

Related