Hedronite · Ops Lesson · 01-Earth-DevOps / CloudFormation StackSets · Fri 2026-09-25 · v2 (Rust)

Rust cargo script StackSet instance status census with aws-sdk-cloudformation

One file, one manifest in its header, one compiled binary. The census reads every stack instance and groups the ones that need a human.

Lesson Class: Ops (DevOps + Rust + CloudFormation StackSets)
Ops Stack: cargo script · aws-sdk-cloudformation 1.129.0 · tokio · Nix writers
Paired Dev: Rust chunk_by group runs via PyO3 + Maturin
Paired Cert: AWS DOP StackSets permission models / operation preferences
Grounding: DOP notes StackSets · SAP cloudformation.md · AWS SDK for Rust dev guide · docs.rs
Paginate
into_paginator().items() streams Summaries lazily.
Group
BTreeMap keyed by (set, status, detailed).
Verified
cargo check exit 0 on stable 1.96; not run against AWS.
Compile the census. Read the reason on every OUTDATED row.

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

One file, one manifest in its header, one compiled binary. The census reads every stack instance and groups the ones that need a human.

§I — Frame

This lesson replaces the Python v1 of the same census. The AWS surface is unchanged: the StackSet instance table. DOP bootcamp notes put the stack set in one region of an administrator account and define a stack instance as a reference to a stack in one target account and region. An instance can exist without a stack, and when creation fails it keeps the reason. Ops prints those reasons. It does not retry, update, or delete.

What changes is the tool. System scripting moves from a Python CLI to cargo script: a single .rs file whose Cargo manifest sits in a frontmatter block at the top. Cargo resolves and caches the dependencies, compiles once, and reuses the build on the next run.

§II — Three pieces in one file

PieceWhere it livesJob
Frontmatter manifestBetween the --- fences under the shebangEdition plus aws-config, aws-sdk-cloudformation, tokio
Paginated readsinto_paginator().items().send()Stream every stack set, then every instance, without handling NextToken
Grouped summaryBTreeMap<(set, status, detailed), Vec<String>>Sorted, deterministic rollup of account/region members

The AWS SDK for Rust developer guide names the pattern: paginated operation builders expose into_paginator(), which returns a stream you drive with .next().await. On docs.rs, ListStackInstancesPaginator::items() flattens the pages into individual Summaries entries, and no request goes out until the stream is polled.

§III — Mechanism: the script

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

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

use std::collections::BTreeMap;

use aws_config::BehaviorVersion;
use aws_sdk_cloudformation::types::{CallAs, StackSetStatus};
use aws_sdk_cloudformation::{Client, Error};

#[derive(Debug)]
struct InstanceRow {
    stack_set: String,
    account: String,
    region: String,
    status: String,
    detailed: String,
    drift: String,
    reason: String,
}

async fn stack_set_names(client: &Client, call_as: &CallAs) -> Result<Vec<String>, Error> {
    let mut names = Vec::new();
    let mut summaries = client
        .list_stack_sets()
        .status(StackSetStatus::Active)
        .call_as(call_as.clone())
        .into_paginator()
        .items()
        .send();
    while let Some(summary) = summaries.next().await {
        if let Some(name) = summary?.stack_set_name() {
            names.push(name.to_string());
        }
    }
    Ok(names)
}

async fn census(client: &Client, call_as: &CallAs) -> Result<Vec<InstanceRow>, Error> {
    let mut rows = Vec::new();
    for name in stack_set_names(client, call_as).await? {
        let mut instances = client
            .list_stack_instances()
            .stack_set_name(&name)
            .call_as(call_as.clone())
            .into_paginator()
            .items()
            .send();
        while let Some(inst) = instances.next().await {
            let inst = inst?;
            rows.push(InstanceRow {
                stack_set: name.clone(),
                account: inst.account().unwrap_or("?").to_string(),
                region: inst.region().unwrap_or("?").to_string(),
                status: inst.status().map_or("UNKNOWN", |s| s.as_str()).to_string(),
                detailed: inst
                    .stack_instance_status()
                    .and_then(|s| s.detailed_status())
                    .map_or("", |d| d.as_str())
                    .to_string(),
                drift: inst.drift_status().map_or("NOT_CHECKED", |d| d.as_str()).to_string(),
                reason: inst.status_reason().unwrap_or("").to_string(),
            });
        }
    }
    Ok(rows)
}

type GroupKey<'a> = (&'a str, &'a str, &'a str);

fn summarize(rows: &[InstanceRow]) -> BTreeMap<GroupKey<'_>, Vec<String>> {
    let mut groups: BTreeMap<GroupKey<'_>, Vec<String>> = BTreeMap::new();
    for r in rows {
        groups
            .entry((r.stack_set.as_str(), r.status.as_str(), r.detailed.as_str()))
            .or_default()
            .push(format!("{}/{}", r.account, r.region));
    }
    groups
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    let config = aws_config::defaults(BehaviorVersion::latest()).load().await;
    let client = Client::new(&config);
    let call_as = match std::env::var("CALL_AS").as_deref() {
        Ok("DELEGATED_ADMIN") => CallAs::DelegatedAdmin,
        _ => CallAs::SelfValue,
    };
    let rows = census(&client, &call_as).await?;
    println!("instances={}", rows.len());
    for ((set, status, detail), members) in summarize(&rows) {
        println!("{set} status={status} detail={detail} n={} [{}]", members.len(), members.join(", "));
    }
    for r in rows.iter().filter(|r| !(r.status == "CURRENT" && r.drift == "IN_SYNC")) {
        println!(
            "attention {} {}/{} status={} drift={} reason={:?}",
            r.stack_set, r.account, r.region, r.status, r.drift, r.reason
        );
    }
    Ok(())
}

Four rules fall out.

**Rule one. OUTDATED is the operator signal.** The stack set can read ACTIVE while individual instances failed. The attention lines carry StatusReason, which usually names the cause: a missing execution role, a name collision, a service quota.

**Rule two. The BTreeMap key is the report.** Keying on (stack set, status, detailed status) gives a sorted, repeatable rollup, so two runs diff cleanly. entry(..).or_default() builds each group in one pass with no pre-sort.

Rule three. An empty census is a region question first. aws_config::defaults takes the region from the environment or profile. Stack sets are regional objects in the admin account, and the wrong region returns zero rows that read like a clean bill.

**Rule four. CallAs must match the caller.** CallAs::SelfValue is the Rust name for the API's SELF, because Self is a keyword. A delegated administrator sets CALL_AS=DELEGATED_ADMIN to see service-managed sets.

Output shape, illustrative only (this script was compile-checked, never run against an AWS account):

instances=5
baseline-iam status=CURRENT detail=SUCCEEDED n=1 [111111111111/us-east-1]
baseline-iam status=OUTDATED detail=FAILED n=2 [222222222222/us-east-1, 333333333333/us-east-1]
attention baseline-iam 222222222222/us-east-1 status=OUTDATED drift=NOT_CHECKED reason="..."

§IV — Build and wrap

Verification done on the lab Mac: the frontmatter manifest and body were copied into a scratch bin crate, and cargo check passed on stable rustc 1.96.0 with aws-config 1.12.0, aws-sdk-cloudformation 1.129.0, and tokio 1.53.1, no warnings. The -Zscript entry point needs a nightly Cargo, and the lab Mac has none installed, so the script form itself was not executed.

Nix wrapper, optional. pkgs.writers.writeRust compiles a single file with bare rustc and cannot fetch crates, so the wrapper calls cargo script instead:

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

It parses and instantiates to a derivation on the lab Mac. It is not hermetic: it expects a rustup-managed nightly on PATH. A flake that pins a nightly toolchain closes that gap.

§V — What not to do

Do not add update_stack_set, create_stack_instances, or delete_stack_instances calls to the census. Do not trigger detect_stack_set_drift from here; it starts a stack set operation and competes with rollouts. Do not collect every page into memory with try_collect when the stream is large; drive .next().await. Do not claim the example output above came from a live account. Do not re-teach SQS redrive (09-22) or CloudWatch alarms (09-10) as the primary drill.

§VI — Close instruction

Run cargo check on the scratch crate form, then run the script with a nightly Cargo against the admin account in its home region. Count OUTDATED and INOPERABLE groups per stack set and read one StatusReason to the owner of that account. Pair: Dev groups the same rows with slice::chunk_by in a Rust crate that Python calls through PyO3; Cert opens StackSets permission models and operation preferences for DOP.

Related