# AWS cost guardrails

**Students** run everything in [For students](#for-students), in their own AWS
account. **Instructors** have extra capabilities, in
[For instructors](#for-instructors). Enforcement differs between them — where
the server holds the line and where it does not yet is set out in
[Who can do what](#who-can-do-what).

The `aws-de` labs run in your own AWS account. `cin90 aws` puts a ceiling on
what that can cost, and tears the labs down afterwards.

These commands talk to AWS with AWS credentials — the same ones `aws sts
get-caller-identity` uses. Only `cin90 aws prove` also reads your coderin90.com
token, because it submits to the server; `setup`, `guard`, `status` and `nuke`
never do.

## Configuration

`$XDG_CONFIG_HOME/cin90/aws.json`, or `~/.config/cin90/aws.json`:

```json
{
  "alert_email": "you@example.com",
  "regions": ["eu-west-1"],
  "budget_amount": 100,
  "course_start": "2026-09-01",
  "protected_accounts": ["999988887777"],
  "allowed_accounts": []
}
```

Only `alert_email` has no default — a budget with no subscriber is a budget
nobody hears about, and `cin90 aws setup` refuses without it.

`protected_accounts` is never operated on, whatever else is set. A non-empty
`allowed_accounts` is the only set of accounts that can be. An account named in
both is refused: the lists disagreeing is a misconfiguration, and the one that
means "never touch this" wins.

Tagging defaults to `course=aws-de`. Nothing outside that tag is ever
considered for teardown.

## Who can do what

Enforcement is per capability, not one blanket rule. Check the row you care
about.

| | Student | Instructor | Enforced by |
|---|---|---|---|
| `cin90 aws setup` / `guard` / `status` / `nuke` | yes | yes, in their own account | your AWS credentials |
| `cin90 aws prove` | yes | yes | cohort roster |
| Filing evidence as a **rehearsal** (`cin90 aws selftest`) | no | yes | server, `not-instructor` (cin90-rewrite#1087) |
| `cin90 kickoff preview` / `run` | no | yes | server, `not-instructor` |

`cin90 kickoff` is genuinely server-enforced: a non-instructor is refused with
a `not-instructor` problem response whatever their local config says.

The rehearsal flag is meant to be as well. `cin90 aws selftest` is the only
command that sets it, since `cin90 aws prove` always sends `rehearsal: false`
and has no flag for it. The server check that refuses it from anyone who does
not teach the cohort is cin90-rewrite#1087. See [Rehearsals](#rehearsals).

Every AWS command operates on whichever AWS account the caller's own
credentials resolve to, and nothing in the CLI accepts another account as a
target. Be precise about what that guarantees: the boundary is your AWS
credential chain, not the CLI. An instructor holding a cross-account role in a
student's account — through `AWS_PROFILE`, an assumed role, or `AWS_*` in the
environment — resolves to that account like any other, and `nuke` would act on
it. The `protected_accounts` and `allowed_accounts` config exists to stop you
operating on the *wrong* account of your own, not to grant or withhold reach
into somebody else's.

Evidence is filed against the caller's own identity. A student cannot file
evidence for another student, and the server refuses a caller who is not on the
cohort roster at all.

# For students

## Day one

```sh
cin90 aws setup
```

Creates a $100 annual cost budget with alerts at 25%, 50% and 75%, plus a
forecast alert at 100%; a `cin90-budget-deny-create` policy; a budget action
that attaches that policy automatically at 75%; and a `cin90` Athena workgroup
capped at 5 GB scanned per query.

The budget is created with credits and refunds **excluded**. With credits
counted, net cost sits near zero however much you spend, so nothing ever
alerts and the first you hear of it is when the credits run out.

Run it again any time — everything it creates, it also recognises.

Two cases it will tell you about rather than paper over:

- **Signed in as root.** Root cannot be restricted by any policy, so the budget
  action is skipped. Create an IAM user and run setup again as that user.
- **An IAM Identity Center role** (`AWSReservedSSO_…`). The policy attaches, but
  Identity Center rewrites those roles on every provisioning run and drops it.

## After the Redshift and Glue labs

```sh
cin90 aws guard
```

Caps Redshift Serverless base capacity at 8 RPUs with a 5 RPU-hour daily limit,
and Glue jobs at 30 minutes and 2 workers. Glue's own default timeout is 48
hours.

Run it after the labs that create those resources — a cap cannot be applied to
something that does not exist yet. Run it **before** you reach 75% of budget:
past that the deny policy is attached, and it blocks Redshift's update actions,
which is how capacity comes down as well as up.

`guard` writes, so it honours `cin90:protect=true` the same way teardown does: a
protected workgroup or Glue job is left at whatever size you set it. An existing
usage limit that does not enforce the daily cap is repaired; one that is
*tighter* than the course default is left alone.

## Checking spend

```sh
cin90 aws status
cin90 aws status --no-cost     # skip Cost Explorer; AWS bills $0.01 per query
```

Shows spend by service, credits applied, what is left of the budget, and
everything currently running — including untagged resources that bill by the
hour. Elastic IPs are listed whether or not they are attached: associated
public IPv4 bills too.

The window matches the budget's own period. With `course_start` set, the budget
is created to start on that date and spend is reported from it; without one,
both run over the calendar year. They have to agree, or "remaining" is a month
of spending subtracted from a year's allowance.

Cost Explorer takes about 24 hours to populate after it is first enabled. On
day one `status` says so and still reports what is running.

## Proving you built the lab

```sh
cin90 aws prove --lab day4 <cohort>/<assignment>
```

Captures what the lab asked you to build: every resource carrying the course
tag, plus a shape check per service saying the lab *ran* rather than that a
resource was created — the Glue job has a script, a run history and a last run
that succeeded, the bucket has objects, the workgroup is available.

Each check reports one of three things, and the third is not a failure:

- **pass** — the check found what it expected.
- **fail** — it did not.
- **unavailable** — nobody could look. A missing permission, a service that
  answered an error. It is recorded with the reason and is never graded as a
  fail: the two are different facts.

The capture is written to `.cin90/aws-evidence.json` **before** anything is
uploaded. Run it before `cin90 aws nuke`, which destroys exactly what the
evidence describes.

The capture is submitted to coderin90.com. If the upload fails for any reason,
the command still exits zero and tells you where the file is: the capture is
complete on disk and `prove` can be run again to retry, but the resources it
describes may not survive a teardown in between.

## Working through a lab

```sh
cin90 aws lab --plain <cohort>/<assignment>
```

Fetches the checklist your instructor published for the assignment and checks
each item against what carries the course tag and the lab's tag in your
account. Each item is one of:

- **done**: enough resources of that kind exist and every listed check
  passed on each of them.
- **waiting**: none found yet, or fewer than the item asks for.
- **failing**: found, but a check failed. The line names the check.
- **unknown**: nobody could look. A check could not run, or discovery could
  not read part of the account (listed under `coverage:`). This is never
  graded as failing.

The checklist is fetched before anything touches AWS. If you are not on the
cohort, or the assignment has no checklist, the command stops without making
any AWS call.

`--plain` is one read-only evaluation for scripts and CI. It writes nothing,
not even `.cin90/`, and uploads nothing. The exit code carries the verdict:

| Code | Meaning |
|---|---|
| 0 | every item is done |
| 3 | some items are waiting or failing, none unknown |
| 4 | at least one item is unknown |
| 5 | the assignment has no checklist yet |
| 1 | anything else: not signed in, not enrolled, assignment not found, a checklist the CLI cannot use, an account outside `allowed_accounts`, discovery failed |

Without `--plain`, the command needs a terminal, and opens an interactive
view of the same checklist:

| Key | What it does |
|---|---|
| `r` | check again (writes nothing) |
| `f` | save the capture on screen under `.cin90/aws-lab/captures`, then file every pending capture with the server |
| `F` | first return captures the server rejected to pending (after `cin90 login`, or once you are on the roster), then as `f` |
| `t` | save the capture on screen, then plan a teardown of this lab |
| `q` | quit; the first press warns if captures are still unfiled |

A saved capture is never overwritten. One the server did not answer for stays
pending and goes with the next `f`. When the view closes, the command prints
the last report and how many captures are still unfiled.

### Tearing the lab down from the view

`t` saves the capture on screen first, because once the lab is gone it cannot
be captured again, and the plan screen says whether that capture is filed yet.
It then lists what carries the course tag and this lab's tag, exactly what
`cin90 aws nuke --lab <lab>` would find: no student filter, no untagged
burners, and the guardrails kept. Each resource is marked `delete`, `skip`
(with the tag that protects it) or `manual` (with the reason). A long plan
scrolls with the arrow keys; the counts line always shows the totals.

To confirm, type the lab name exactly and press Enter. Every printable key is
typed into the name, including `q`, `r`, `f` and `t`, so only Esc or Ctrl-C
go back. Nothing is trimmed or case-folded. Before anything is deleted, Enter
checks the account and the plan again: if the credentials now resolve to
another account, or anything that would change what gets deleted has changed
(a resource retagged, protected, or newly created), nothing is deleted, the
new plan is shown, and you type the name again. Otherwise it runs the plan
that is on screen and nothing else.

While it runs, each resource shows `deleting`, `deleted`, `retry (pass N)` or
`failed`. `q` or Ctrl-C stops it: the delete in flight is cancelled and shown
`pending` with everything not reached, never `failed`. Further presses do
nothing until the outcome is on screen.

Afterwards the view looks again and shows what is still present, separately
from what is pending and what failed. A resource reported deleted that is
still listed is usually the tagging API catching up; press `t` again in a
minute. Skipped and manual items are listed as the second look found them.
Until that look shows nothing left and no place it could not read, the
screen shows the command that finishes the job, with every region the view
covered as its own `--region` flag:

```sh
cin90 aws nuke --lab day4 --region eu-west-1 --execute --confirm-account 123456789012
```

The command prints the same line on stderr when the view closes, including
when the terminal is closed during a teardown or while it is being checked.

The checklist endpoint is not live on coderin90.com yet. Until it is, the
command refuses with "not implemented yet on the server".

## Tearing down

```sh
cin90 aws nuke                                     # prints a plan, deletes nothing
cin90 aws nuke --lab day4                          # narrow it
cin90 aws nuke --execute --confirm-account 123456789012
```

`nuke` prints a plan and stops. `--execute` requires `--confirm-account` with
the account id typed out in full, compared exactly — no trimming, no prefix
match. That one step is what makes you look at *which* account you are about to
empty.

The plan has three parts, and reads all three out:

- **delete** — ordered so nothing restarts what is being deleted. Schedulers
  first, then jobs, then warehouses, then network, then buckets.
- **skip** — `cin90:protect=true` is never touched. Resources tagged
  `cin90:role=guardrail` survive too, unless `--final` is passed, so a
  mid-course teardown leaves the caps standing.
- **manual** — VPCs, subnets, security groups, IAM. Nothing deletes these:
  their blast radius is your whole account rather than one lab. A resource
  whose tags could not be read lands here too, rather than being deleted with
  `cin90:protect` unchecked.

`--include-untagged-burners` extends teardown to resources nobody tagged.
`status` always shows them; `nuke` only touches them when asked. A burner that
*is* tagged still has to agree with `--lab` and `--student`: the flag widens
teardown to untagged resources, not to other people's labs.

Deletion runs up to five passes, 45 seconds apart, because dependencies clear
in order — an Elastic IP cannot be released until its NAT gateway is gone.
Anything still blocked at the end is reported **pending**, and running `nuke`
again clears it. **failed** is different: another pass will not help.

# For instructors

Everything above applies to your own AWS account too — the guardrails are the
same guardrails. This section is the part students do not have.

## Confirming the tooling works before a class

You want to know the whole path works on your machine before thirty people
depend on it:

```sh
cin90 aws selftest <cohort>/<assignment>
```

It creates one throwaway S3 bucket, tagged `lab=selftest` by the same call that
creates it, and puts one object in it. Then it runs the real capture — the same
discovery and probes `prove` uses — checks that the capture found the bucket and
that its probe passed, and files it through the real evidence endpoint as a
rehearsal. Last, it tears the bucket down with `nuke`'s own teardown, whatever
failed before that.

`selftest passed` means the whole path works: credentials, tagging, discovery,
probes, auth, and the server. Anything else exits non-zero and names what
failed. Unlike `prove`, a refused upload fails the command: the upload is part
of what is being tested. So does a row the server stored *without* the
rehearsal flag. The failure names that row's id, because it is gradeable work
and has to be removed.

Your AWS credentials need `s3:CreateBucket`, `s3:TagResource` (the tags go on
at creation), `s3:PutObject` and the usual discovery and teardown permissions.

Do not run two selftests in the same account at once. Each one's teardown
deletes everything tagged `lab=selftest`, including the other run's bucket in
the middle of its capture.

The bucket bills for as long as it exists. Teardown still runs after Ctrl-C,
and after a teardown pass it deletes the bucket by name in case discovery did
not see it. If teardown does not finish, the command prints this line, which
finds what was left:

```sh
cin90 aws nuke --lab selftest --execute --confirm-account <your account id>
```

The teardown deletes everything carrying the course tag and `lab=selftest`, not
only the bucket this run made, so an earlier interrupted run is cleaned up too.
Do not use `lab=selftest` for anything you want to keep.

## Rehearsals

Evidence carrying the rehearsal flag is stored and shown to you, and is
**never counted toward anyone's work**. It is how a self-test result is told
apart from a student's real submission.

`cin90 aws selftest` is the only command that sets it. `cin90 aws prove` always
sends `rehearsal: false` and has no flag for it.

The flag is instructor-only. cin90-rewrite#1087 makes the evidence endpoint
refuse a rehearsal from anyone who does not teach the cohort, with a
`not-instructor` problem. A rehearsal row a student could forge would make the
self-test signal unreadable, and a student who set the flag by accident would
file work that is silently never graded.

`selftest` does not take the server's word on the other half either. If the row
comes back without `rehearsal: true`, the command fails and names it rather
than passing over a gradeable row.

## Reading what a student filed

Each resource in a capture carries probes, and there are three outcomes. The
third is not a worse version of the second:

- **pass** — the check found what it expected.
- **fail** — it did not. The lab did not produce what it should have.
- **unavailable** — nobody could look. A missing IAM permission, a service that
  errored. **This is not a failing lab** and must not be read as one. It is an
  infrastructure fact about the student's account, and treating it as evidence
  of missing work turns a permissions problem into an accusation.

An empty capture is also a real answer: it means the command ran and found
nothing tagged, which is different from the student never having run it.

# End of the course

This one is for whoever owns the account — student or instructor, in their own.


```sh
cin90 aws nuke --execute --confirm-account <id> --final
```

`--final` removes the guardrails themselves along with everything else.
