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.
<!-- 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
| Piece | Where it lives | Job |
|---|---|---|
| Frontmatter manifest | Between the --- fences under the shebang | Edition plus aws-config, aws-sdk-cloudformation, tokio |
| Paginated reads | into_paginator().items().send() | Stream every stack set, then every instance, without handling NextToken |
| Grouped summary | BTreeMap<(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
- Prior arc: Python SQS DLQ and redrive policy census
- Pair hub: Cross-References/domains/01-Earth-DevOps
- Language hub: Cross-References/dev-languages/Rust
- Grounding tome: DevOps Engineer Professional notes (CloudFormation / StackSets)