Caltech Library logo
skip to main content

AWS Tools — awsops — Design

2026-07-01: Retargeted to Go. See DECISIONS.md (“Retarget implementation from Bash to Go”) for why. ec2_ami_manager.bash remains in this repo, unchanged, as the working reference for the behavior this document describes, until the Go version reaches parity and is verified against real AWS.

2026-07-02: Domain-picker redesign. Scope is expanding beyond EC2/AMI to Key Management, S3 (including static website hosting), and CloudFront. The single flat main menu is replaced by a domain picker (Compute / Key Management / S3 / CloudFront) with a domain-scoped submenu underneath — see “Navigation: Domain Picker” below. This is additive: Compute’s existing 12 features (below) are unchanged in behavior, only regrouped under one submenu. Real-AWS verification of Compute (Phase 16) continues in parallel with this redesign — see PLAN.md. See DECISIONS.md, “Redesign navigation as a domain picker; add Key Management, S3, and CloudFront domains”.

2026-07-08: Bash retired. Phase 16’s real-AWS verification (TEST_PLAN_REAL_AWS.txt, 112/112 checks) is complete. ec2_ami_manager.bash, ami_copy.bash, ami_copy_basic_steps.md, and tests/*.bats have been deleted from this repo; awsops is now the sole implementation and the working reference for Compute domain behavior. See DECISIONS.md, “Retire ec2_ami_manager.bash, ami_copy.bash, and the Bash test suite”.

Overview

An interactive Go CLI for administering AWS EC2 instances and AMIs for this team’s infrastructure, across two regions (us-west-1, us-west-2 — narrowed from an original four; see DECISIONS.md, “Narrow configured regions to us-west-1/us-west-2”). The tool is general-purpose — nothing in its mechanisms (tagging, backup archival, cloud-init inspection) is RDM-specific (see DECISIONS.md, “Name the CLI binary awsops”) — but this team’s Invenio RDM deployments are its primary use case today, and several features (the Postgres/OpenSearch/Redis crash-consistency guidance, the backup-directory convention) are grounded in operational facts observed on those instances. Beyond EC2/AMI lifecycle management, the tool is meant to help with ongoing administration of these instances (e.g. backup hygiene, inspecting deployed configuration) and to speed up and de-risk development, test, and deployment workflows more broadly — not just be a thin wrapper over RunInstances/CreateImage/DeregisterImage. The core EC2/AMI feature set and UX below are unchanged from the Bash version — only the implementation language and AWS access layer change; everything from “Show/Export Cloud-Init” onward is new scope that came out of this design review.

This team’s AWS footprint splits into two broad concerns: deploying and operating Invenio RDM instances (Compute: EC2/AMI, plus the SSH key pairs they launch with) and publishing static websites (S3 buckets as origin, CloudFront serving and caching in front of them). The tool’s navigation now reflects that split directly — see “Navigation: Domain Picker” below — rather than growing a single ever-longer menu.

Non-Goals

awsops is an interactive replacement for ad hoc, day-2 AWS Console work — “what’s running right now, let me tag/start/stop/snapshot/back up this specific thing” — with this team’s safety gates and domain knowledge (crash-consistency guidance, backup hygiene, the Project/ Environment tagging convention) built in. It is deliberately not:

Scope decisions in this document (curated instance-type lists over full API listings, a fixed Project/Environment tagging vocabulary rather than free-form policy, no “pick a different AMI” recovery path once one is committed) follow from staying inside this lane — see DECISIONS.md for the specific trade-offs each one made.

Configuration

awsops reads its own operational settings — never AWS credentials or profile selection, which remain entirely the AWS SDK’s responsibility via its standard chain (~/.aws/credentials, ~/.aws/config, environment variables, SSO; see “Assumptions” #1, unchanged) — from an optional YAML file at ~/.awsops (overridable with -config <path>). See DECISIONS.md, “Add a ~/.awsops YAML config file for awsops’ own operational settings”.

regions

regions:
  - us-west-1
  - us-west-2

Defaults to [us-west-1, us-west-2] if unset or the file doesn’t exist (see DECISIONS.md, “Narrow configured regions to us-west-1/us-west-2”). These are the regions every region-fanned-out feature (instance/AMI listing, key pair listing, official Ubuntu AMI lookup, and eventually Key Management once it ships) iterates over.

backup_directories

backup_directories:
  - pattern: "rdm-*"
    directory: /opt/rdm_sql_backups
  - pattern: "newt-*"
    directory: /opt/newt/backups

An ordered list of glob patterns (path.Match syntax: *, ?, [...]), matched against the picked instance’s Name tag, first match wins. Feature 11 (Backup Archive & Trim) uses the matching rule’s directory to pre-fill its “Backup directory” prompt — still an editable value, never a silent default, consistent with that prompt’s other fields. No match (including an untagged instance with a blank Name) leaves the prompt with no default, exactly like today. See DECISIONS.md, “Configure per-instance backup directories by Name pattern”. Built to accommodate, not yet implementing, further settings this same file would naturally hold: per-domain defaults once S3/CloudFront ship (e.g. a default bucket), or overrides for the curated instance-type/Ubuntu-release lists if those ever need site-specific tuning.

User Experience Flow

┌─────────────────────────────────────────────────────────────────┐
│  awsops — AWS Operations CLI                                    │
├─────────────────────────────────────────────────────────────────┤
│  Pick a domain:                                                 │
│  1) Compute (EC2 & AMI)                                         │
│  2) Key Management                                              │
│  3) S3 (Buckets & Static Websites)                              │
│  4) CloudFront                                                  │
│  5) Exit                                                        │
└─────────────────────────────────────────────────────────────────┘

Picking a domain drops into that domain’s own listing + menu loop. The Compute domain (below) keeps today’s exact shape:

┌─────────────────────────────────────────────────────────────────┐
│  awsops — Compute (EC2 & AMI)                                   │
│  Regions: us-west-1, us-west-2                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ===== CURRENT EC2 INSTANCES =====                              │
│  ID           Name        State    AMI ID        Region         │
│  i-012345...  web-server  running  ami-abc123...  us-east-1     │
│  i-67890...   db-server   stopped  ami-def456...  us-west-2     │
│                                                                 │
│  ===== AVAILABLE AMIs (owned by account) =====                  │
│  AMI ID          Name              Creation Date    Region      │
│  ami-abc123...  base-ubuntu-2404  2026-01-15      us-east-1     │
│  ami-def456...  app-server-v2     2026-02-20      us-west-2     │
│  ami-ghi789...  custom-ami        2026-03-10      us-east-1     │
│                                                                 │
│  ===== COMPUTE MENU =====                                       │
│  1) Show resource lists                                         │
│  2) Create EC2 instance from AMI                                │
│  3) Create EC2 instance from cloud-init YAML                    │
│  4) Start EC2 instance                                          │
│  5) Stop EC2 instance                                           │
│  6) Terminate EC2 instance                                      │
│  7) Manage tags for an instance or AMI                          │
│  8) Create AMI from EC2 instance (running or stopped)           │
│  9) Remove AMI                                                  │
│ 10) Show/export cloud-init for an instance or AMI               │
│ 11) Archive stale backups to S3 and trim disk space             │
│ 12) Back to domain picker                                       │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

(Illustrative — the real listing also includes Project, Environment, Public IP, and Private IP columns; see Feature 1 and Feature 12 below.) Key Management, S3, and CloudFront follow the same listing-then-menu pattern; their specific listings and menus are documented under their own feature sections below rather than repeated here.

On startup, before any resource listing or menu, the tool shows the domain picker above. Picking a domain fetches and displays that domain’s resources, then shows a domain-scoped numbered menu — its own “Refresh” and “Back to domain picker” entries, in addition to that domain’s actions — and returns to that same domain’s listing after each action completes. “Back to domain picker” returns to the picker; “Exit” from inside any domain menu exits the whole tool, not just that domain, so an operator working in S3 doesn’t have to back out twice.

Domain-specific notes: - Compute fans its resource listing out across all four configured regions (Feature 1), unchanged from today. - Key Management also fans out across the configured regions — key pairs are a per-region resource. - S3 buckets share a single global namespace but each has a home region; the listing shows that region per bucket, the same style as Compute’s per-resource region column today. - CloudFront is a genuinely global service (its control-plane API is always us-east-1, regardless of where origins live) — its listing is not region-fanned-out at all, the one domain that behaves differently here.

This structure is additive and mechanical: each domain’s menu loop and resource-listing call were already separable pieces of Compute’s existing single-menu implementation (see “Architecture” below), so introducing the domain picker is a refactor of internal/ui/internal/workflow’s menu wiring, not a rewrite of any of Compute’s existing workflows.

Color Output

When color is enabled (respects NO_COLOR and falls back to plain text on a non-TTY, ui.ColorEnabled()), two things are colorized: - The STATE column in the instance listing (running=green, stopped/terminated=red, pending/stopping=yellow). - Every pick-list prompt’s header line (e.g. “Select an instance to start”), printed in bold before the numbered list it introduces – so picking the wrong main-menu action (e.g. Start instead of Stop) is visible immediately, without reading through the list first. See DECISIONS.md, “Highlight PickList’s prompt header when color is enabled”.

Terminal UI Architecture: Menus, Actions, Lists, and Managers (Design Addendum, 2026-07-10)

Status: designed 2026-07-10, implementation starting with the S3 domain. Supersedes “S3 Resource List Display — Paged, Accessible- Compatible” above (internal/ui.PagedTable/DisplayBuckets are retired, not extended) and the 0.0.1-era framing of huh as merely “the leading candidate for the next release” (DECISIONS.md, “0.0.1 scope: ship on termlib as-is…”). Full rationale and rejected alternatives: DECISIONS.md, “Deprecate termlib; standardize on huh/bubbletea before 0.0.2.”

Motivation. clasm exists to replace ad hoc AWS Console clicking and one-off Bash scripts with something a whole team can use fluidly, without each person memorizing a different command sequence per screen – otherwise it offers no real advantage over writing Bash against the AWS CLI directly. That only works if every screen, however different its purpose, looks and behaves like part of the same tool. termlib (a stepping-stone library used to figure out the menu/action shape this tool needed, not the destination) is being removed entirely before 0.0.2 in favor of standardizing on huh and bubbletea exclusively.

Taxonomy. Every navigation path in clasm passes through one or more connectors (never a destination themselves) to reach one of three destinations:

Connectors: - Guide menu — a huh.Select today (works well, simple to teach), or a small bubbletea screen later if a menu ever needs more than a flat pick-one, over a small, fixed set of options (e.g. the S3 domain’s 6 actions). Routes the operator toward a destination below. - Picker — chooses one instance of a fetched, variable-length resource collection (a specific S3 bucket, EC2 instance, AMI, key pair, …) to feed into an action wizard or manager. Distinct from a guide menu because the option list is dynamic and can be long enough to need scrolling/filtering, not a small fixed menu. See “Picker tier” below.

Destinations: - Action wizard — a short prompt sequence (huh fields, or termlib prompts as they’re migrated off) that gathers parameters and executes one thing (Create Bucket, Delete Bucket, …). - List — a read-only, scrollable display of a resource collection (S3 buckets, EC2 instances, AMIs, key pairs). Was internal/ui.PagedTable (plain sequential prints); becomes a bubbletea component (below) so it shares real chrome with the manager tier instead of approximating it with static text. - Manager — a persistent, stateful bubbletea screen for ongoing interactive work against a resource. The S3 object manager (internal/filemanager) is the only one today.

Shared chrome: internal/tui. The file manager’s box-drawing/ legend/scrolling code (internal/filemanager/view.go) is already implemented as pure functions with no dependency on filemanager.ModeltopBorder, bottomBorder, divider, splitDivider, mergeDivider, boxLine, boxRow2, padOrTruncate, runeLen, stripANSI, truncateVisible, scrollWindow, styleRow. These move, unchanged, into a new internal/tui package, and internal/filemanager imports them instead of keeping its own copy — one implementation, not two that can drift apart. internal/ui (PickList, DisplayInstances/Images/KeyPairs, Confirm, color helpers) stays in place for as long as termlib-based call sites remain; it shrinks over the course of the termlib removal rather than being replaced in one step.

List tier: a new internal/tui component. Replaces internal/ui.PagedTable/DisplayBuckets. A single bordered box (no split panes), a frozen header row, a scrollable body reusing the same cursor-centered scrollWindow logic the file manager’s panes use, sized to the real terminal via tea.WindowSizeMsg (not a fixed or computed-from-termlib page size — tea.WindowSizeMsg is sent to Update once when the program starts and again on every resize, except on Windows, which has no SIGWINCH; an initial size still arrives there, just no live updates), a legend bar at the bottom, rendered inline (no tea.WithAltScreen, matching every other screen in this app). Quitting (q) returns to the menu it was opened from — for “List S3 Buckets” that’s the S3 menu, not ErrBackToDomainPicker (which backs out of the whole S3 domain, one level further up).

Picker tier: a new internal/tui component. The user’s own framing: “this UI should feel the same whether I select a bucket, an AMI or an EC2 instance” – resource selection is exactly the kind of screen huh.Select would otherwise handle, but huh.Select’s own rendering is visually distinct from the bordered-box/legend-bar chrome the List and Manager tiers use, which would make the S3 domain alone show two different visual languages depending on whether a screen shows a resource or picks one. internal/tui.PickerModel reuses the exact same chrome as ListViewModel (TopBorder/BoxLine/Divider/ScrollWindow/ StyleRow/BottomBorder) – same box, same scroll behavior – but adds selection: Enter chooses the row under the cursor and returns it, q/ctrl+c cancels. A dedicated PickerModel rather than a Selectable bool flag on ListViewModel, matching this project’s existing preference for small, purpose-built components over one component doing everything (the same reasoning already used to keep the List tier itself separate from filemanager.Model).

Like ListViewModel, PickerModel works on pre-rendered rows and returns an index, not a typed value, so internal/tui doesn’t need Go generics – each caller maps the chosen index back into its own typed slice (buckets[idx], instances[idx], …), the same pattern pickS3MenuItem already uses for s3MenuItems.

Filtering, included from the start (the user’s own request: “this allows someone to go directly to the thing they want if they know the name or part of the name”): / enters filter-typing mode (matching the keybinding table below, and huh.Select’s own default / binding – not an always-on type-ahead, since j/k must stay unambiguous navigation keys), narrows visible rows by case-insensitive substring match against each row’s rendered text, Enter commits and keeps navigating the narrowed list, Esc clears it – the same shape as internal/filemanager’s pane filter and ui.PickList’s own existing substring-filter convention (filterByLabel), just applied to a real chrome-consistent box instead of a plain numbered list or huh’s default field styling.

The map: every current resource-selection call site. internal/ui.PickList is used in ~40 places today; most are guide-menu-shaped (a small, fixed set of actions – “Choose an option,” “Add/Update/Remove,” Instance-vs- AMI kind pickers) and are NOT Picker candidates, they stay as menu-tier PickList/huh.Select. The ones below select one instance of a fetched resource collection and are the Picker tier’s actual scope, listed here as the “clear map of specific instances using the common model” the user asked for – S3 buckets are the pilot (Phase 20.4); everything else is deliberately not scheduled yet (see “Not decided yet” below), listed so the eventual conversions have a concrete checklist to work from rather than needing to be rediscovered later:

This was the original, preliminary map, written when Phase 20.4 was the only conversion underway. It’s superseded by the “Full conversion punch list” immediately below, which is the one kept current – statuses here are left as a historical snapshot except for the terminal state (every row below is now done); see the fuller table for the actual phase each one landed in and, where it differs, which tier it was actually reclassified into (e.g. “Storage class (transition)” and “Instance type (curated list)” turned out to be small fixed option sets and became Menu tier, not Picker tier).

Resource Domain Current call site(s) Status
S3 bucket S3 bucket_website.go:43, bucket_lifecycle.go:530, bucket_delete.go:31 done, Phase 20.4
S3 lifecycle rule S3 bucket_lifecycle.go:107,447,491 done, Phase 20.12 (Picker tier)
Storage class (transition) S3 bucket_lifecycle.go:261,364 done, Phase 20.11 (reclassified: Menu tier)
EC2 instance Compute backup_archive.go:77, create_ami_from_instance.go:94, show_cloud_init.go:35, power_state.go:42,113, terminate_instance.go:50, manage_tags.go:135 done, Phase 20.12
AMI Compute launch_from_cloud_init.go:31, launch_instance.go:55, show_cloud_init.go:60, manage_tags.go:154, remove_ami.go:61 done, Phase 20.12
Subnet Compute launch_prompts.go:43 done, Phase 20.12
Instance type (curated list) Compute launch_prompts.go:169 done, Phase 20.11 (reclassified: Menu tier)
IAM instance profile / role Compute create_instance_profile.go:71,106 done, Phase 20.12
Region S3, Key Management bucket_create.go:26, keymgmt_common.go:25 done, Phase 20.11 (reclassified: Menu tier)
Key pair Key Management create_key_pair.go:94, keypair_delete.go:47 done, Phase 20.12

Full conversion punch list (2026-07-10). The map above covers only Picker candidates. Per the user’s request for “a clear map of specific instances using the common model” spanning all three targets, here is every current ui.PickList/ui.Display* call site in the codebase, classified by which tier it converts to. Nothing here is scheduled beyond what’s already marked done — this is a checklist to work from, not a committed roadmap (see “Not decided yet” below).

Menu tier (→ huh.Select, small fixed option sets — not fetched, not long enough to need scrolling/filtering):

Menu Call site(s) Status
S3 domain menu s3_menu.go (pickS3MenuItem) done, Phase 20.2/20.7
Lifecycle rule action (Add/Edit/Remove/View) bucket_lifecycle.go (pickLifecycleAction) done, Phase 20.9
Domain picker domain_menu.go:60 done, Phase 20.10
Compute main menu menu.go:85 done, Phase 20.10
Key Management menu keymgmt_menu.go:59 done, Phase 20.10
Instance-vs-AMI kind (show/export cloud-init) show_cloud_init.go:22 done, Phase 20.11
Instance-vs-AMI kind (manage tags) manage_tags.go:119 done, Phase 20.11
Tag Add/Update/Remove action manage_tags.go:171 done, Phase 20.11
Select a tag to update/remove (small, in-memory, per-resource) manage_tags.go:196,212 done, Phase 20.11
Bucket-purpose enum (Website/Backup/Internal) bucket_create.go:71 done, Phase 20.11
Region (configured list, S3) bucket_create.go:26 done, Phase 20.11
Region (configured list, Key Management) keymgmt_common.go:25 done, Phase 20.11
Instance type (curated static list + “Other”) launch_prompts.go:169 done, Phase 20.11
Storage class, guided backup flow (curated 4) bucket_lifecycle.go:296 done, Phase 20.11
Storage class, generic editor (full enum) bucket_lifecycle.go:399 done, Phase 20.11
AZ-incompatibility remediation choice instance_type_az_check.go:144 done, Phase 20.11
ENA-incompatibility remediation choice instance_type_ena_check.go:66 done, Phase 20.11

Picker tier (→ tui.Picker, fetched/variable-length resource collections):

Resource Call site(s) Status
S3 bucket bucket_website.go/bucket_lifecycle.go/bucket_delete.go (pickBucket) done, Phase 20.4
EC2 instance backup_archive.go:77, create_ami_from_instance.go:94, show_cloud_init.go:35, power_state.go:42,113, terminate_instance.go:50, manage_tags.go:135 done, Phase 20.12
AMI launch_from_cloud_init.go:31, launch_instance.go:55, show_cloud_init.go:60, manage_tags.go:154, remove_ami.go:61 done, Phase 20.12
Subnet launch_prompts.go:43 done, Phase 20.12
IAM instance profile (fetched, + none/create-new) create_instance_profile.go:71 done, Phase 20.12
IAM role (fetched, to attach) create_instance_profile.go:106 done, Phase 20.12
Key pair (fetched, + create-new) create_key_pair.go:94 done, Phase 20.12
Key pair (fetched, to delete) keypair_delete.go:47 done, Phase 20.12
S3 lifecycle rule (view/edit/remove) bucket_lifecycle.go:142,482,526 done, Phase 20.12

List tier (→ tui.ListView, read-only resource displays — the ui.Display* family):

Listing Function Status
S3 buckets ui.DisplayBuckets done, Phase 20.6
EC2 instances ui.DisplayInstances done, Phase 20.13
AMIs ui.DisplayImages done, Phase 20.13
Key pairs ui.DisplayKeyPairs done, Phase 20.13

ListViewModel gained the same /-filter behavior as PickerModel (Phase 20.14) – see “Filtering, included from the start” above, which was written for Picker but always intended for both per the keybinding table below; the two models now share a filterState helper (internal/tui/filter.go) rather than each keeping its own copy.

Keybinding conventions (DECISIONS.md, “TUI keybinding conventions”):

Key Action Where
q Back to the parent screen Everywhere
/, k/j Navigate / scroll Menus, pickers, lists, managers
Enter Select / confirm / submit Menus, pickers, lists, wizards
Esc Cancel the in-progress action only — never closes a screen Wizards, in-progress input
/ Filter Menus, pickers, lists, managers
Legend bar Always visible at the bottom of every screen, showing that screen’s actual keys Every screen

Menus (still huh.Select for now) can’t show a custom footer entry: huh’s help line is built solely from the focused field’s own KeyBinds(), and SelectKeyMap has no quit/back entry to add one to without forking huh. q is bound at the Form level instead (Form.WithKeyMap, adding "q" alongside the default "ctrl+c" on KeyMap.Quit), which already resolves to the same huh.ErrUserAborted path RunS3Menu’s mapS3MenuPickerErr maps to ErrBackToDomainPicker — no new dispatch logic needed. Since that won’t appear in huh’s own footer, a short static hint line is printed above the menu instead (e.g. “(q to go back)”), fully within this project’s own control. Picker, list, and manager tiers, which fully own their rendering, show q in a real legend bar instead.

Accessibility. Screen-reader/non-TTY accessible rendering is not a requirement for clasm going forward — it’s an internal tool for Library staff managing AWS resources, not public-facing (distinct from the Frontend Guidelines’ A11y requirement for browser-side Web Components elsewhere in this workspace, which this doesn’t affect). The prior session’s huh-accessible-mode pipe-testability investigation (DECISIONS.md, 2026-07-10, “huh fields are pipe-testable…”) remains factually accurate but is no longer load-bearing for design decisions; testing shifts to teatest (already proven against internal/filemanager’s Model) for anything built as a real bubbletea component.

Superseded 2026-07-13 by “Removing termlib: Action Wizards and Output” immediately below, which is now the committed plan for exactly this remaining work.

Removing termlib: Action Wizards and Output (Design Addendum, 2026-07-13)

Status: designed 2026-07-13, not yet implemented. Closes out the “Not decided yet” paragraph above by giving the remaining ~40 termlib call sites (every action wizard, plus internal/ui’s lower-level helpers) a committed conversion plan, per DECISIONS.md, “Remove termlib entirely: input via huh, output via io.Writer.” Menu/Picker/List tiers are unaffected — they’re already fully converted (Phase 20.2-20.14).

Surface audit. Every remaining termlib symbol was traced to its actual call sites (not just its imports) across the ~44 files that still reference it:

Symbol Refs Actual usage
termlib.Terminal 109 Only .Printf/.Println/.Refresh() are ever called anywhere in this codebase — no cursor movement, no color state (Move/Clear/SetFgColor/etc. are unused). It’s used purely as a buffered io.Writer.
termlib.LineEditor 83 Only .Prompt() is ever called. History (AppendHistory/SetHistory/History), tab-completion (Completer), $EDITOR composition, and multi-line input (Ctrl+J) are all unused — no call site needs anything beyond single-line text entry with Ctrl+C/Ctrl+D handling.
termlib.PadRight / Truncate 40 / 12 Column formatting, internal/ui/display.go only.
termlib.Bold / Reset / Green / Red / Yellow 4 / 5 / 2 / 1 / 1 ANSI constants, internal/ui/color.go (Highlight) and display.go (stateColor) only.
termlib.FormatDuration 2 10-line m:ss/h:mm:ss formatter, progress_ticker.go and create_ami_from_instance.go.
termlib.New / NewLineEditor 6 / 3 Constructors — cmd/clasm/main.go and tests only.
termlib.ErrInterrupted 4 Ctrl+C sentinel from LineEditor.Prompt, checked in isExitSignal/mapMenuPickerErr-style error mapping.

Only three files call le.Prompt() directly: internal/ui/prompt.go (Prompt), internal/ui/picklist.go (PickList — see below), and internal/workflow/confirm.go (Confirm/ConfirmDestructive). Every other file that imports termlib merely threads t/le through its own signature to reach one of these three, or to call t.Println/ t.Printf directly for status/error text. This means le *termlib.LineEditor disappears from every signature in the codebase once these three functions are rebuilt — there is no other direct caller to migrate.

internal/ui.PickList is dead code. Every real call site was already converted to huh.Select/tui.Picker in the Phase 20.2-20.13 punch list; only comments still reference it (internal/tui/picker.go, object_browser.go, s3_menu.go). internal/ui/picklist.go and picklist_test.go are deleted outright, not migrated.

Mapping: termlib construct → replacement.

Sequencing. Unlike the Menu/Picker/List conversions (each independent, one call site at a time), this refactor changes a type threaded through nearly every internal/workflow function signature — Go requires the whole module to compile together, so it can’t ship as 40 independent single-file changes. See PLAN.md Phase 20.15 (foundational helpers) and 20.16 (mechanical propagation, domain by domain) for the ordered work breakdown.

Chrome Standardization: A Shared lipgloss Palette (Design Addendum, 2026-07-13)

Status: designed 2026-07-13, not yet implemented. With termlib gone (Phase 20.15/20.16), every screen in clasm is now either a huh field or a bubbletea component — but they don’t yet look like one system. huh’s default theme (ThemeCharm) renders a colorful indigo/fuchsia/ cream card with a thick colored left border; internal/tui’s List/ Picker/Manager chrome (box.go/style.go) is plain ASCII box-drawing with no color at all beyond the cursor row’s reverse-video and the instance-state column’s green/red/yellow. An operator moving from a Menu-tier huh.Select into a Picker or List sees two unrelated visual languages depending on which tier they happen to be in, not a deliberate design.

A single shared accent, not a repaint. Rather than inventing a new palette, this reuses the one color huh’s own default theme already established and has been on screen since Phase 20.2: the adaptive indigo ThemeCharm uses for focused titles/borders (#5A56E0 light / #7571F9 dark — already light/dark-terminal-aware via lipgloss.AdaptiveColor). Two pieces:

  1. tui.Theme() *huh.Theme — built from huh.ThemeBase() (structural styling only: spacing, borders-as-shapes, no color) with only the indigo accent applied to focused titles, borders, and the selected-option marker (bold + indigo, mirroring exactly what ThemeCharm already does for those same elements) — deliberately omitting ThemeCharm’s fuchsia highlight, cream backgrounds, and green/red confirm-button colors. A single accent suits an internal ops tool better than a five-color rainbow; this is a restrained subset of ThemeCharm, not a new invention.
  2. internal/tui/box.go’s border/title rendering (TopBorder, BottomBorder, Divider, SplitDivider, MergeDivider) styled with the same indigo + bold via lipgloss.NewStyle(). Because ListViewModel, PickerModel, and internal/filemanager (the one Manager-tier screen) all call these same shared functions directly (confirmed: no per-tier copies survived the Phase 20.5 extraction), styling them once re-skins all three tiers in a single change.

Deliberately unchanged: - Cursor-row selection stays reverse-video (style.go’s StyleRow) — the existing mc/ranger/WinSCP-style convention is already correct and unrelated to color branding. - Instance-state colors (green=running, red=stopped/terminated, yellow=pending/stopping, internal/ui/display.go’s stateColor) stay as-is — semantic data indicators, not decorative chrome. - NO_COLOR/non-TTY handling needs no new plumbing: lipgloss already detects both automatically (via termenv, checking the output file descriptor and the NO_COLOR env var) and no-ops its own ANSI codes accordingly — the existing manual ui.ColorEnabled() gate stays scoped to the STATE column it already governs, unrelated to this addendum’s border/title styling.

Every huh.NewForm(...) call site gets .WithTheme(tui.Theme()). Traced directly (not estimated): there are exactly five constructors in the whole app — internal/ui/prompt.go (Prompt), internal/workflow/ confirm.go (Confirm, ConfirmDestructive), internal/workflow/ domain_menu.go (runMenuField, the Menu tier’s shared entry point), and internal/workflow/object_browser.go (runFieldWithHelp). Every other huh.Select/huh.Input/huh.Confirm in the app already funnels through one of these, a direct consequence of the shared-helper pattern established across Phase 20.2-20.16 — so five edits cover the entire app’s huh surface.

Progress ticker becomes a real spinner. internal/workflow/ progress_ticker.go’s periodic " ... waiting for AMI (elapsed 1:23)" printed line is the one place in the app that isn’t a huh field or a bubbletea component — deferred out of the termlib-removal pass specifically to avoid mixing “remove termlib” with “improve chrome” (DECISIONS.md, “Remove termlib entirely…”). It becomes a small bubbletea component using github.com/charmbracelet/bubbles/spinner (already a direct dependency — bubbles is the same charm-ecosystem package key already comes from), styled with the same indigo accent, running inline for the duration of the wait and clearing itself when the operation completes.

object_browser.go’s bucket pre-flight moves onto PickerModel. Resolves the one item DESIGN.md’s “Terminal UI Architecture” section left explicitly undecided: BrowseAndManageObjects’s selectBucket (a bare huh.Select over buckets) is the only bucket-selection call site in the app that isn’t already on pickBucket/tui.RunPicker (every other one converted in Phase 20.4). Replacing it makes bucket selection look identical everywhere, and lets this one call site drop its own bespoke huhCancelledIsNil-for-bucket-selection path in favor of the same cancelledIsNil convention pickBucket’s other callers already use. confirmLink (huh.Confirm) and the local-directory huh.Input in the same function are wizard-shaped, not picker-shaped, and stay as huh fields.

UX Refinements: Contextual Text and a Full Box Border (Design Addendum, 2026-07-13)

Two follow-on refinements to the chrome-standardization pass above, requested directly after it landed.

Every Menu/Picker-tier screen gains contextual description text. The domain picker (and every other bare-title screen) explained nothing about what each choice meant — an operator new to a screen had only the title and the row labels to go on. Every Menu-tier huh.Select gains a .Description(...) (huh’s own built-in field, previously unused); tui.PickerConfig gains a Description string, rendered as its own line directly below the top border (the same chrome shape Header already has: the line itself plus a Divider), above any Header/ rows. See DECISIONS.md, “Contextual description text on Menu/Picker- tier screens,” for the full call-site inventory and the reasoning behind which functions got a threaded parameter versus a single description written directly into the function body. List-tier’s tabular “Show resource lists” displays deliberately don’t get this — they aren’t “just a pick list,” and their column headers already carry the relevant context.

huh fields get a full box border, matching tui’s chrome shape, not just its color. Phase 20.17 gave huh and tui the same accent color, but huh.ThemeBase()’s default is a thick bar down the left side of a field only — not the full ┌─┐│ │└─┘ rectangle tui/box.go draws. tui.Theme()’s Focused.Base/Focused.Card now use lipgloss.NormalBorder() on all four sides, in the shared accent, with balanced padding replacing the old single-side clearance — see DECISIONS.md, “huh fields get a full box border to match tui’s chrome.”

Full-height Menu Tier (Design Addendum, 2026-07-20)

Status: designed 2026-07-20. Resolves the “full height” half of Phase 20.24’s deferred request (see that phase’s own note and continue_next_time.txt, 2026-07-14 hand-off), which was deliberately left unimplemented pending clarification of what “full height” meant for the huh-based Menu tier. Clarified directly: the wrapping chrome should carry a real terminal height, and every Menu-tier huh.Select should be told how many rows it has to work with — not just the root domain picker.

Mechanism. huh.Select.Height(n) (and the equivalent huh.Form.WithHeight(n), which cascades to every field in a group) already does exactly this — confirmed by reading huh v1.0.0’s source, not assumed: updateViewportHeight (field_select.go) subtracts the title/description lines from n before sizing the options viewport, floored at huh’s own minHeight; Select.View() then renders through lipgloss.Style.Base.Height(s.height), and lipgloss’s Style.Height pads short content with blank lines to reach that height. So a 3-item menu given Height(24) still renders as a 24-line box — the wrapping chrome stays full-height even when a menu has far fewer than 24 options, matching every List/Picker-tier screen’s existing behavior.

Why this isn’t automatic today. Every Menu-tier huh.Form already receives tea.WindowSizeMsg (bubbletea always sends one at startup, and again on resize except on Windows) whether it runs via the plain form.Run() path or runMenuField’s quitKeyGuard-wrapped tea.NewProgram(guard).Run() path (filterable fields). But huh.Form.Update’s own WindowSizeMsg handling (form.go:533-554) only shrinks a group to min(neededHeight, msg.Height), and only when f.height == 0 (i.e., nothing has called WithHeight yet) — it never grows short content to fill unused space. Reaching full height requires explicitly calling WithHeight with the real terminal height.

Live tracking, not a one-shot read. Rather than reading the terminal size once via x/term.GetSize before constructing the form (simple, but blind to a resize mid-menu), the Menu tier intercepts tea.WindowSizeMsg itself and calls WithHeight on every resize — the same pattern internal/tui/picker.go and listview.go already use for the Picker/List tier, so the Menu tier gains the identical live-resize behavior instead of a second, weaker mechanism. runMenuField’s existing quitKeyGuard wrapper (used today only for the filterable- field path, to guard the Quit keybinding while filtering) is the natural place to add this interception — it already wraps *huh.Form in a custom tea.Model. The plain form.Run() path (used by non-filterable Menu-tier selects today) has no such wrapper at all; this addendum extends the same wrapper to that path too, so both go through one tea.Model that both guards the quit key (when filtering) and maintains full-height sizing (always), rather than diverging by field type.

Scope: every Menu-tier huh.Select, not just the root domain picker. Phase 20.24 explicitly declined to make only the root picker full-height, reasoning that it would look inconsistent with every Menu-tier screen one level deeper (S3/EC2/Key Management submenus), undoing the chrome-consistency work of Phases 20.17-20.25. Since runMenuField is the single shared entry point every Menu-tier huh.Select already runs through (menu.go, s3_menu.go, keymgmt_menu.go, domain_menu.go’s pickString/pickComparable), fixing it there gets every depth for free — no per-call-site change.

Reserved chrome. runMenuField prints its "(q to go back)" hint via a plain fmt.Fprintln(w, hint) before the form runs, outside the form’s own bordered box — a line of chrome the form’s height budget doesn’t otherwise know about. Whatever height gets passed to WithHeight must reserve for this hint line (and the box’s own top/bottom border, already accounted for by tui.Theme()’s border padding), or the combined output ends up one line taller than the terminal.

Resolved during implementation (2026-07-20; see PLAN.md Phase 20.26): the reserved-line count is 2, not 1 as first assumed here – confirmed empirically by rendering a real form (the actual tui.Theme(), not a stand-in) at a known WithHeight and counting the output. One line is the hint; the second is huh.Form’s own trailing help/keybindings footer, which renders below whatever height WithHeight(n) was given (a form asked for height n renders n+1 lines total) – not something this addendum had accounted for. tui.Theme()’s border/padding turned out not to change the count at all. runMenuField’s non-filtering form.Run() path was folded into the same quitKeyGuard-wrapped path rather than left as a second mechanism – in practice every current call site already builds a *huh.Select (which satisfies filteringField), so that path was already dead code, but unifying it means a future non-Select field costs nothing extra.

Launch Templates (Design Addendum, 2026-07-20)

Status: designed 2026-07-20, targeted for v0.0.2 alongside the IMDSv2 fix below. v0.0.1 is already piloting in production (unreleased – no git tag yet, version.go still reads 0.0.1 – but in active use), so this is additive: new Compute-domain menu entries and a new EC2 client surface, no change to any existing v0.0.1 workflow’s behavior. Directly requested (notes-from-tom.txt) and confirmed as v0.0.2’s headline feature. Folds in the IMDSv2 bug (TODO.md, Bugs) as one design pass, since both touch the same MetadataOptions concept. The tags-screen fix, backup-bucket-default, and top-level cross-resource tag management (TODO.md’s other open items) are deliberately out of scope here – v0.0.2 material, but their own design pass.

The operator’s actual flow (clarified directly, not assumed): a cloud-init YAML file is authored first; it gets applied to create a launch template, which “more fully encapsulates” what a running instance needs (RDM’s software requirements, primarily). Over time the YAML changes (e.g. RDM adds a new dependency) and that change is applied as a new version of the existing template – never an in-place edit, matching the operator’s own framing of “new template version” as the upgrade unit. CreateLaunchTemplate/ CreateLaunchTemplateVersion fit this directly: both take a LaunchTemplateData/RequestLaunchTemplateData struct (AMI, instance type, subnet, security groups, IAM instance profile, tags, UserData, MetadataOptions, …) constructed directly by the caller – there is no dependency on an existing EC2 instance (that’s a different, unused API: GetLaunchTemplateData derives a template from a running instance’s live config, which is not this flow). So “cloud-init YAML → new template” and “cloud-init YAML → new version of an existing template” are both just a template-data struct built from the same AMI/instance-type/subnet/security-group/IAM-profile/tag prompts Feature 3 already collects, with the YAML’s content (base64-encoded) as UserData.

New EC2 client surface. internal/awsclient/ec2.go’s EC2API interface (currently ~20 methods, one line per SDK call, no per-feature narrowing) gains: CreateLaunchTemplate, CreateLaunchTemplateVersion, DescribeLaunchTemplates, DescribeLaunchTemplateVersions, ModifyLaunchTemplate (sets which version number $Default points to, via its DefaultVersion *string field), DeleteLaunchTemplate, DeleteLaunchTemplateVersions – mirrored into logging_ec2.go’s wrapper, matching how every existing EC2 call is already logged.

Data model. A new internal/inventory.LaunchTemplate (list-tier, one row per template, aggregated across regions like Image/Instance already are): TemplateID, Name, DefaultVersion, LatestVersion, Region, Project, Environment (from DescribeLaunchTemplates, whose tags come back the same []types.Tag shape Image/Instance already decode). A separate per-version detail type (returned by DescribeLaunchTemplateVersions for one template) carries the fields actually shown/edited: version number, create date, AMI, instance type, IAM instance profile, security groups, subnet, tags, and MetadataOptions (below). Deliberately not modeling the rest of RequestLaunchTemplateData’s much larger surface (block device mappings, network interfaces, capacity reservations, CPU options, …) – out of scope, matching this project’s existing preference for a curated field set over the full AWS struct (the same restraint Image/Instance already apply).

Two distinct SDK types for “IMDSv2 required,” not one. AWS uses types.HttpTokensState/HttpTokensStateRequired for a plain RunInstances/ModifyInstanceMetadataOptions call, but a different type, types.LaunchTemplateHttpTokensState/ LaunchTemplateHttpTokensStateRequired, inside a template’s LaunchTemplateInstanceMetadataOptionsRequest – confirmed by reading the SDK’s enums.go, not assumed. Both get set going forward (see “IMDSv2 enforcement” below); implementation needs to use the right one in each of the two call sites rather than assuming one type covers both.

New Compute-domain menu entries

Peer entries alongside the existing ones (menu.go’s mainMenuItems), not nested under a new sub-menu – matching how Create-from-AMI and Create-from-Cloud-Init already sit side by side rather than behind a “Create” sub-picker:

Tagging and safety-gate parity (A6)

types.ResourceTypeLaunchTemplate exists in the SDK, so CreateLaunchTemplate’s own TagSpecifications field can reuse launch_execute.go’s existing buildTagSpecification(resourceType, tags) unchanged – it already takes a types.ResourceType parameter, not one hardcoded to instances. The Project/Environment convention (Feature 12) and the Environment=production confirmation gate (Feature 9’s pattern) both extend to launch templates exactly as described above.

IMDSv2 enforcement (closes the TODO.md bug, extends to templates)

Three call sites, all going to required going forward, none of them a prompt (this is a security default, not an operator choice, per the operator’s own framing: “we want to follow security recommendations by default”):

  1. Plain RunInstances (Features 2 and 3, launch_execute.go) – currently sets no MetadataOptions at all (confirmed by reading launch_execute.go:51-60 – the original TODO.md bug). Gains MetadataOptions: &types.InstanceMetadataOptionsRequest{HttpTokens: types.HttpTokensStateRequired}.
  2. Every new launch template (CreateLaunchTemplate) – its RequestLaunchTemplateData.MetadataOptions gets types.LaunchTemplateInstanceMetadataOptionsRequest{HttpTokens: types.LaunchTemplateHttpTokensStateRequired} unconditionally.
  3. Existing templates missing it – flagged passively in Show Launch Template / List Launch Templates (above), not auto-fixed – changing an existing template’s MetadataOptions is a new version, same as any other template change, and shouldn’t happen silently behind an operator’s back.

Not decided yet

Left for the implementation plan: the exact curated field list for the per-version detail type (beyond the fields named above); whether ModifyLaunchTemplateVersion’s description-setting capability (a per-version free-text label, separate from the UserData itself) is worth surfacing; and test coverage shape for the diff/no-op-detection path (go-udiff’s own test suite covers the algorithm itself – this project’s tests need to cover the identical-content-skips-a-version and different-content-shows-a-diff-then-creates-a-version branches, via the same accessible-mode pipe-testing convention every other Menu-tier workflow already uses).

Real-usage follow-ups (2026-07-20)

The first real-AWS pass over this feature (create a template, launch from it, sync, promote, list, delete) surfaced one bug and three UX gaps, all addressed the same day – see DECISIONS.md, “Accept v-prefixed launch template versions” and “Launch Template version history, scrollable diffs, and split Show resource lists,” and PLAN.md Phase 20.28:

Tag Management Domain (Design Addendum, 2026-07-20)

Status: implemented 2026-07-20 (all five resource types, including S3 Bucket; see PLAN.md Phase 20.30 for the implementation record and DECISIONS.md for both the original design decision and the S3 apply- closure follow-up decision). “Tag Management” (this addendum’s assumed name) is what shipped – no objection was raised during implementation. One addition beyond what’s described below: removing a bucket’s last tag calls s3:DeleteBucketTagging rather than PutBucketTagging with an empty TagSet, proactively matching ManageBucketLifecyclePolicies’ own DeleteBucketLifecycle precedent for the same “replace the whole set” operation shape (not itself confirmed against real AWS yet). What follows is the original design, requested directly (notes-from-tom.txt, TODO.md: “a top level menu item for managing tags across resources (EC2, AMI, S3, etc)”), explicitly alongside keeping the existing per-resource entry points (“continue to support tag management at the point we are working with individual resources”). This addendum only covers the new cross-resource domain; Phase 20.29’s Compute-scoped Manage Tags (loop until ‘q’, Show tags choice, refresh-after-change) stays exactly as it is and is unaffected.

A fourth Domain Picker entry, not a menu item nested in an existing domain. DomainActions/domainItems (domain_menu.go) gain “Tag Management” alongside Compute/Key Management/S3 – the only screen in the app that needs to reach across all three existing domains’ resources in one place, so it doesn’t fit inside any single one of them (Key Management, for instance, has nothing to do with S3 buckets). Like the other three domains, entering it runs its own refresh (fetching all five taggable resource types fresh, across all regions) on every entry, independent of whether the operator has visited Compute/S3/Key Management yet this session – matching the existing domain convention exactly, not a special case.

Five taggable resource types, confirmed against the actual AWS APIs involved, not assumed:

Resource Tag API Notes
EC2 Instance ec2:CreateTags/DeleteTags Already working (Manage Tags, Phase 20.29)
AMI ec2:CreateTags/DeleteTags Already working (Manage Tags, Phase 20.29)
Launch Template ec2:CreateTags/DeleteTags New – targets the template resource’s own tags, not the TagSpecifications baked into a version’s UserData for instances launched from it (a version-creation concept already covered by Sync, Phase 20.27/20.28)
Key Pair ec2:CreateTags/DeleteTags New ground – confirmed types.KeyPairInfo has its own Tags field and the generic EC2 tagging API applies, but clasm has never fetched, displayed, or set a key pair’s tags before this
S3 Bucket s3:GetBucketTagging/PutBucketTagging New – a different API shape from the other four: PutBucketTagging replaces the entire tag set (confirmed via bucket_create.go’s existing, narrower use of it for the fixed “Purpose” tag), so Add/Update/Remove here means a transparent read-modify-write (fetch the current set, change one entry, PUT the whole set back) – the operator still sees “add/update/remove one tag,” same as everywhere else. Accepted risk: a concurrent external change to the bucket’s tags could be silently overwritten by the read-modify-write, consistent with this tool not doing concurrency control anywhere else either.

Reuses Phase 20.29’s loop mechanism, generalized. manageTagsForResource/ applyOneTagChange (Phase 20.29) already take a pluggable fetchTags closure; this phase further generalizes applyOneTagChange to also take a pluggable apply closure (currently hardcoded to ApplyTagChange’s EC2-specific CreateTags/DeleteTags calls), so the exact same loop/action-picker/confirm/Show-tags-choice UI serves both the four EC2-backed resource kinds and S3 buckets uniformly – only the fetch/apply closures differ per kind, not the workflow shape. This avoids a second, parallel tag-editing UI just for S3.

Resource selection reuses existing Picker-tier helpers where they already exist (pickInstance, pickImage, pickLaunchTemplate, pickBucket – confirmed all four already exist and are directly reusable) and adds one new one, pickKeyPair, matching the same shape.

Key pair tags are not surfaced in Key Management’s existing “Show resource lists” display for this phase – add/update/remove via the new Tag Management domain is the v1 scope; extending inventory.KeyPair with its own Project/Environment columns (matching Instance/Image’s own convention) is a separate, smaller follow-on if wanted later, not bundled in here.

“Show all tags,” scoped to one resource type at a time – not one combined table across all five. Raised directly after this addendum was first drafted: a way to see every tag on every resource of a type, not just edit one resource at a time. Reuses the same resource-type picker as editing (Instance/AMI/Launch Template/Key Pair/Bucket), then a List-tier table of every resource of that type with a flattened “Tags” column (every key=value pair, not just Project/Environment) – the same shape as Compute’s existing “Show instances/AMIs/launch templates” listings, just one more per-type listing with the full tag map decoded instead of filtered to the two convention tags. Deliberately not one table spanning all five types at once: they don’t share a natural row shape (different ID formats, and critically, tag key sets vary per resource, so fixed columns don’t work regardless – you’d end up with one flattened text column either way, at which point five separate, type-scoped listings read better than one forced-together table). For the four EC2-backed types this needs no new AWS call – their existing list calls already return full tags inline, just currently decoded down to Project/Environment only (inventory.Instance/Image/LaunchTemplate/KeyPair); for S3 it means one GetBucketTagging call per bucket (generalizing bucketPurpose’s existing single-tag-filtered pattern to return the whole tag map, run across every bucket rather than one at a time).

Someday/maybe, explicitly out of scope for this phase: a compliance/audit-style report across all five resource types showing which resources are missing tags (entirely, or missing Project/Environment specifically) – a different query shape than “Show all tags” (which shows what each resource has), raised as likely to be asked for later but not scoped now (see TODO.md).

Resolved: the domain’s own name in the picker is “Tag Management,” as assumed above.

Configurable EBS Root Volume Size (Design Addendum, 2026-07-21)

Status: designed 2026-07-21, targeted for the next release. Closes the TODO.md bug confirmed in production 2026-07-22: a launch template built for a 250GB InvenioRDM comparison instance instead produced an 8GB root volume (the stock Ubuntu 24.04 AMI default), which silently exhausted mid-provisioning and took real troubleshooting to diagnose (disk, not Docker/Redis, was the actual cause). Worked around live via aws ec2 modify-volume + growpart/resize2fs run by hand over SSH, entirely outside clasm. This addendum has two parts: setting the size at creation time (closes the bug directly), and resizing an already-running instance’s root volume (automates the operator’s own manual workaround so it doesn’t have to happen outside clasm next time).

Scope decision: root volume only, not a general block-device-mapping editor. Matches the Launch Templates addendum’s existing restraint (RequestLaunchTemplateData’s full surface – network interfaces, capacity reservations, non-root volumes, … – stays out of scope). The confirmed real-world need is exactly “the root volume defaults to 8GB and I sometimes need 250-500GB,” not additional data volumes or per-volume type/IOPS tuning. If a project ever needs a second data volume, that stays a manual post-launch step (aws ec2 create-volume + attach), same as today.

Part 1 — Setting root volume size at creation

New field, LaunchInstanceParams.RootVolumeSizeGB int32 and LaunchInstanceParams.RootDeviceName string (launch_instance.go). RootDeviceName is carried alongside the size (rather than re-resolved at RunInstances/CreateLaunchTemplate time) because the builder functions (Launch, buildRequestLaunchTemplateData) only ever see LaunchInstanceParams, not the source inventory.Image.

New helper, describeImageRootVolume(ctx, client, imageID) (deviceName string, defaultGB int32, err error) (launch_instance.go or a new ebs_size.go): one ec2:DescribeImages call scoped to ImageIds: [imageID], reading Images[0].RootDeviceName and the matching entry in Images[0].BlockDeviceMappings for .Ebs.VolumeSize. This is a second DescribeImages call beyond the one that built the pick list (inventory.Image doesn’t carry block device mappings, and adding them to a list-tier struct used everywhere else for a value only needed once, at the moment of collecting launch params, would be scope creep) – acceptable latency, matching imagesWithOfficialUbuntu’s existing per-AMI DescribeImages calls at pick-list-build time.

One new prompt, shared by both collection paths. Added to collectLaunchInstanceParams (launch_instance.go) and collectLaunchInstanceParamsFromCloudInit (launch_from_cloud_init.go) at the same point in both – right after ensureInstanceTypeENACompatible finalizes the instance type, before the key-pair prompt, grouping “compute sizing” (instance type + volume size) together. Prompt text: “Root EBS volume size in GB”, defaulting to the AMI’s own default (describeImageRootVolume’s defaultGB, via ui.WithDefault) but editable. Validated to be a positive integer no smaller than the AMI’s own default – AWS’s own ModifyVolume/RunInstances already reject shrinking below the source snapshot’s size, but failing fast at the prompt with a clear message beats a RunInstances API error after every other param has already been collected. Because both collection paths are shared: - Feature 2 (Create EC2 Instance from AMI) and Feature 3 (Create EC2 Instance from Cloud-Init YAML) both gain the prompt via collectLaunchInstanceParams. - Create Launch Template from Cloud-Init YAML gains it for free, since createLaunchTemplateFromCloudInit already calls collectLaunchInstanceParamsFromCloudInit (launch_template_create.go:91). - Create EC2 Instance from Launch Template (the third creation path) is deliberately untouched – the template already bakes in whatever root volume size it was created with; this stays “just another way to create an instance” (DESIGN.md, “Launch Templates”), not a hybrid template-plus-override wizard.

Builder changes. Both Launch (launch_execute.go) and buildRequestLaunchTemplateData (launch_template_create.go) gain a BlockDeviceMappings/LaunchTemplateBlockDeviceMappingRequest entry, built by a new small helper reused by both:

func buildRootBlockDeviceMapping(params LaunchInstanceParams) ...

Only DeviceName and Ebs.VolumeSize are set – every other Ebs field (VolumeType, Iops, Encrypted, DeleteOnTermination) is left unset, which AWS documents as “inherited from the source AMI’s own mapping/snapshot” for any field not explicitly overridden, so this stays additive and doesn’t quietly change volume type or encryption for existing users.

Data model. inventory.LaunchTemplateVersionDetail (internal/inventory/launch_templates.go) gains RootVolumeSizeGB int32 (0 if the version predates this feature or was created outside clasm), decoded from DescribeLaunchTemplateVersions’s LaunchTemplateData.BlockDeviceMappings, surfaced in Show Launch Template’s existing detail display alongside IMDSv2Required.

Part 2 — Resizing an already-running instance’s root volume

New Compute-domain menu entry, “Resize Instance’s Root Volume.” Peer to the existing instance actions (Start/Stop/Terminate/Manage Tags), not nested. Flow:

  1. pickInstance (existing Picker-tier helper).
  2. Resolve the instance’s root EBS volume: DescribeInstances already returns BlockDeviceMappings; match the entry whose DeviceName equals the instance’s RootDeviceName for its Ebs.VolumeId, then DescribeVolumes for its current size (reuses GatherVolumeInfo’s shape, scoped to that one volume).
  3. Prompt the new size in GB, validated >= current size (ModifyVolume cannot shrink a volume; AWS also enforces a 6-hour cooldown between modifications to the same volume, surfaced as a plain error message if hit, not pre-checked client-side).
  4. Confirm, with the same Environment=production extra-warning gate Feature 9 (Remove AMI) established, since this touches a live, possibly-production instance.
  5. ec2:ModifyVolume (new EC2API method), then poll ec2:DescribeVolumesModifications (new EC2API method) until ModificationState is optimizing or completed – AWS’s own guidance is that the volume is safely usable once it leaves modifying, not only once fully completed.
  6. Then automate the OS-side growth via SSM, reusing the existing SSMAPI.SendCommand/GetCommandInvocation mechanism (cloud_init_check.go already polls a command this way for cloud-init status): run an AWS-RunShellScript document that:
    • Resolves the root filesystem’s underlying block device and partition number via findmnt -no SOURCE / (not a hardcoded <disk>1 assumption – NVMe-backed instances name partitions /dev/nvme0n1p1, Xen-backed ones /dev/xvda1; the script splits device-vs-partition-number generically rather than assuming a naming scheme).
    • Runs growpart <disk> <partition-number>.
    • Detects the filesystem type via findmnt -no FSTYPE / and runs resize2fs (ext4) or xfs_growfs (xfs) accordingly.
    • If SSM isn’t online for the instance (same “skip, don’t fail” precedent as checkCloudInitCompletion’s result.Skipped), the workflow reports the volume-level resize as done and prints the same manual growpart/resize2fs instructions the operator used as their real-world workaround, rather than failing the whole action.
    • If findmnt/lsblk reports a layout this script doesn’t recognize (anything beyond a single partition directly on the root device – e.g. LVM, which some newer cloud images use for non-AWS-targeted variants, though Canonical’s official AWS EC2 Ubuntu AMIs are confirmed single-partition, no LVM, as of this writing), the script aborts with a clear message and the same manual-instructions fallback, rather than guessing and risking a partially-grown, inconsistent disk – this mirrors the original incident’s own lesson (a silent, hard-to-diagnose disk problem) closely enough that “fail loud, don’t guess” is the priority here over full automation coverage.

New EC2API surface: ModifyVolume, DescribeVolumesModifications – mirrored into logging_ec2.go’s wrapper, matching every existing EC2 call.

Not decided yet

Exact prompt wording/ordering details (left for the implementation plan); whether the resize workflow’s SSM step needs its own timeout constant or reuses DefaultSSMOnlineTimeout/DefaultCloudInitTimeout; test coverage shape for the findmnt-output-parsing logic (needs fixture-driven unit tests independent of any live SSM round-trip, same as checkCloudInitCompletion’s own tests fake the SSM client rather than hitting real infrastructure).

SSM-Capable Instance Profile Enforcement + Retrofit (Design Addendum, 2026-07-22)

Status: designed 2026-07-22, targeted for v0.0.5 alongside arm64/Graviton support. Closes two related gaps confirmed the same day during live real-AWS verification of “Configurable EBS Root Volume Size” (above): both test instances launched via cloud-init had IamInstanceProfile: null in their RunInstances call – no instance profile at all – so growRootFilesystem’s SSM-based OS-level growth automation could never come online, silently degrading to its printed-manual-instructions fallback every time. Separately, the same day (and once before, setting up an InvenioRDM test instance for S3 access), there was no way to attach/associate an IAM instance profile to an instance already running – only at launch. Today, promptIAMInstanceProfileOrCreate (create_instance_profile.go) offers a "(none)" choice and never checks what permissions a picked/created role actually grants, so an operator can launch (or already has launched) an instance clasm itself can’t manage via SSM – undermining Resize Instance’s Root Volume, checkCloudInitCompletion, and anything else this project builds on SSM going forward.

Decision, mirroring IMDSv2 enforcement’s precedent (above): SSM manageability becomes a hard requirement at launch, not an operator choice – same posture as HttpTokens: required, just enforced via a picker instead of a silent parameter flip, since (unlike IMDSv2) an actual IAM role has to exist and be selected. Three parts:

Part 1 — roleHasSSMPermissions (shared verification helper)

New helper, roleHasSSMPermissions(ctx, iamClient, roleName) (bool, error) (new ssm_iam_check.go or similar): calls iam:ListAttachedRolePolicies for roleName and checks for AWS’s own managed policy, arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore, by ARN. This is a known, deliberate limitation, not an oversight: it does not parse inline policies (iam:ListRolePolicies/ GetRolePolicy) for functionally-equivalent custom permissions – doing so would mean interpreting arbitrary IAM policy JSON to decide whether it grants “enough” SSM access, which is exactly the kind of guessing this project’s “fail loud, don’t guess” convention (Phase 20.31’s own growRootFilesystem) argues against. A role with a custom, non-managed-policy path to the same permissions will be (correctly, if inconveniently) reported as not SSM-capable; the operator can still attach the managed policy via the IAM console outside clasm, matching the existing “clasm attaches existing roles, never authors policies” scope boundary (DECISIONS.md, “2026-07-02 – Support picking or creating an IAM instance profile from within awsops”).

New IAMAPI surface: ListAttachedRolePolicies – mirrored into logging_iam.go’s existing wrapper, matching every other AWS call’s logging convention.

Part 2 — Enforcement at launch

promptIAMInstanceProfileOrCreate’s picker changes in two ways:

  1. The "(none)" choice is removed. An instance profile is now mandatory, same as IMDSv2’s required has no optional escape hatch.
  2. Every entry in the picker – existing profiles and the “create new” flow – is annotated/checked via roleHasSSMPermissions, resolving each profile’s attached role(s) first. An existing profile whose role isn’t SSM-capable is shown but not selectable (or selectable only after a clear inline explanation of why it’s flagged – exact picker-row wording left for the implementation plan); createInstanceProfileForRole’s role picker (pickRole) gets the same annotation before a role can be attached to a new profile. This applies everywhere promptIAMInstanceProfileOrCreate is already called: Create EC2 Instance from AMI, from Cloud-Init YAML, and Create Launch Template from Cloud-Init YAML (all three funnel through the same collection path, per the EBS root-volume-size addendum’s own note about this shared surface).

Part 3 — Retrofit: associate/replace an instance profile on a running instance

General-purpose, not SSM-specific (per the 2026-07-22 scoping decision): a new Compute-domain menu entry, “Associate/replace IAM instance profile,” lets an operator attach any instance profile (picked or created via the same promptIAMInstanceProfileOrCreate flow, SSM-capability shown but not gated here – a running instance might legitimately need a profile for S3 access alone, the exact case that originally surfaced this gap) to an already-running instance. Resolves the instance’s current association first via the existing DescribeIamInstanceProfileAssociations (internal/awsclient/ec2.go – declared in the EC2API interface already, though not actually called anywhere in the workflow package until this feature) to decide which underlying API to call:

New EC2API surface: AssociateIamInstanceProfile, ReplaceIamInstanceProfileAssociation – mirrored into logging_ec2.go.

Not decided yet

Exact picker-row wording for a non-SSM-capable existing profile (shown-but-blocked vs. shown-with-a-confirm-override); whether the Environment=production + type-to-confirm gate (Feature 9’s established pattern, already reused by Resize Instance’s Root Volume) applies to the retrofit workflow; whether roleHasSSMPermissions needs to handle a profile with more than one role (today’s create_instance_profile.go always attaches exactly one, but InstanceProfileInfo.Roles is already a slice); test coverage shape for the IAM-policy-check helper (fixture-driven against a fake IAM client, matching every other AWS-API-backed helper in this project) – all left for the implementation plan.

ARM64 (Graviton) Support + Ubuntu 26.04 LTS (Design Addendum, 2026-07-22)

Status: designed 2026-07-22, targeted for v0.0.5 alongside Phase 20.33/20.34 (already implemented). Raised in a colleague conversation: cost savings using ARM instances outside RDM. Real AWS facts confirmed live (not assumed) before writing this, since this project has a documented history of getting AMI name patterns wrong when not checked (DECISIONS.md, “Offer official Ubuntu LTS AMIs…”): Canonical publishes arm64 Ubuntu AMIs under the exact same naming convention as the existing amd64 entries, just with arm64 swapped for amd64 (confirmed via a live ec2:DescribeImages call, both 24.04/“noble” and 22.04/“jammy”); Ubuntu 26.04 LTS (“resolute”) AMIs are already published in both architectures as of today; and instance types’ ec2:DescribeInstanceTypes ProcessorInfo.SupportedArchitectures values ("arm64"/"x86_64") match AMI Architecture values exactly, confirmed for the specific Graviton types below.

Scope, simplified from an initial larger design. The first pass at this addendum proposed a new architecture-compatibility pre-flight check mirroring ensureInstanceTypeENACompatible (query, then offer “change instance type or abort” if mismatched). Reconsidered smaller: filter the instance-type picker’s choices by the already-picked AMI’s architecture instead, matching the filtering approach just adopted for the IAM-profile/role picker (DECISIONS.md, “Filter non-SSM-capable profiles/roles from the picker, don’t just annotate them”) – there’s no need for a reject-and-retry mechanism if the wrong choice is never offered in the first place. No new pre-flight check, no new incompatibilityChoice variant.

Changes:

  1. inventory.Image gains an Architecture string field, populated the same way EnaSupport already is: imageFromSDK (internal/inventory/images.go, owned AMIs) and listOfficialUbuntuAMIsInRegion (official_ubuntu_amis.go, curated Ubuntu AMIs) both set it directly from the SDK’s own Architecture field (types.ArchitectureValues, a string type – no aws.To* needed).
  2. curatedUbuntuReleases gains arm64 variants of the existing 24.04/ 22.04 entries, plus new 26.04 entries for both architectures – ubuntu-resolute-26.04-{amd64,arm64}-server-* and ubuntu-{noble,jammy}-{24.04,22.04}-arm64-server-*, confirmed patterns as above.
  3. instanceTypeChoice (launch_prompts.go) gains an arch field. Existing t3/m5/c5/r5/t2 entries are tagged "x86_64"; new Graviton entries (t4g.micro/small/medium/large/xlarge, m6g.large/xlarge, c6g.large, r6g.large – mirroring the existing family shapes 1:1) are tagged "arm64". No Graviton equivalent of t2’s “no-ENA-required escape hatch” – every Graviton type is Nitro-based and requires ENA (confirmed live), same as t3/m5/c5/r5.
  4. promptInstanceType gains an arch string parameter: "" means “no filter, show everything” (preserves current behavior exactly); otherwise the choice list is filtered to entries whose arch matches (plus the “Other” free-text escape hatch, always shown regardless). The two top-level collection functions (collectLaunchInstanceParams/collectLaunchInstanceParamsFromCloudInit) pass image.Architecture at their initial call, so the instance-type list only ever offers types compatible with the already-picked AMI. The two remediation call sites inside ensureInstanceTypeENACompatible/ the AZ-incompatibility “change instance type” choice pass "" (unfiltered) – deliberately not threaded through further, since Graviton didn’t exist when older non-ENA AMIs did and the two concerns essentially never interact in practice.

Not decided yet

Whether curatedUbuntuReleases’ entries need reordering/grouping now that the list roughly doubles in size (currently flat, sorted by region/name at pick-list-build time via imagesWithOfficialUbuntu) – left for the implementation plan to assess once the list is actually built.

IAM Profile & Role Management Domain (Design Addendum, 2026-07-23)

Status: designed 2026-07-23, targeted for v0.0.5 alongside Phases 20.33-20.35 (already implemented) – explicitly bundled into the same release rather than deferred, per the user’s direction. Motivated by two questions the AWS Console makes hard to answer quickly: what roles/profiles/policies already exist and where they came from (Caltech Library DLD’s own, ones opened up by IMSS – Caltech’s central IT organization, whose security team is one component of IMSS, not a separate category – for cross-team tooling such as CrowdStrike, or AWS’s own huge managed-policy catalog, all interleaved in one flat list today), and whether an existing one is safe to reuse or a new one is actually needed. See aim_management_and_support_proposal.md for the full problem statement, the paths considered for each open question, and the trade-offs of paths not taken – this addendum records the paths selected.

Revised same day, after further discussion once the initial design pass had already gone into DESIGN.md/DECISIONS.md/PLAN.md: the original pass modeled categorization as a fixed Owner tag with a hardcoded DLD/CentralIT vocabulary. The actual vocabulary isn’t decided yet – it’s pending a demo of this feature and feedback from the user’s group – so DLD-vs-not is now determined by a general, config-driven Origin tag instead (below), and functionally there’s no separate treatment for IMSS vs. AWS-managed: both are equally “not DLD’s to modify.”

A fifth Domain Picker entry, alongside Compute/Key Management/S3/Tag Management – matching Tag Management’s own precedent (Phase 20.30) of introducing a new domain rather than nesting cross-cutting IAM concerns inside Compute. Three List-tier sub-views: Roles, Instance Profiles, Policies (customer-managed by default; AWS-managed policies behind a separate “show AWS-managed” toggle, since that catalog is large and mostly irrelevant to this team’s day-to-day questions). Each list sorts by CreateDate descending by default – answers “what’s recently added” directly from the IAM API, no separate tracking mechanism. Policies are a full top-level kind, symmetric with Role and Instance Profile, not merely something surfaced from inside a role’s detail screen – the “what already exists” question applies to policies just as much as to roles.

Categorization: a new Origin tag, general and config-driven, no hardcoded vocabulary. Origin joins Project/Environment (Feature 12) as a third tag clasm treats as a first-class convention – but unlike those two, neither Origin’s tag-key name nor which value means “DLD-owned” is hardcoded in source. Both are read from a new config section (see “Configuration,” below), mirroring regions/ backup_directories’s existing pattern, left unset by default. clasm reads/writes Origin exactly like any other tag – no new tag-entry mechanism, since manageTags/applyOneTagChange (Phase 20.29/20.30) already accept arbitrary key/value pairs. An absent Origin tag is itself informative: it signals “nobody’s made a categorization call on this resource yet,” which is more useful to the group than silently defaulting it one way or the other.

Read-only guard, enforced by clasm itself, independent of what the active AWS credentials would technically permit – but tagging is explicitly exempt. Any IAM-domain action that would change a role’s, profile’s, or policy’s actual permissions (attach/detach a managed policy, edit a trust policy, delete) checks whether the resource’s Origin tag matches the configured DLD value first, and refuses with a clear message otherwise. Tagging itself is never gated – DLD must still be able to set Origin (or any other tag, e.g. a future support-contact tag) on an IMSS- or AWS-owned resource, since recording who to contact for support is the concrete reason DLD needs to touch these resources at all. An IMSS- or AWS-managed resource’s actual permissions are never one accidental menu selection away from being modified by this tool, but labeling/annotating it is always available.

No dedicated backfill action. Since Origin is an ordinary tag, setting it on a legacy DLD resource that predates this scheme (e.g. ec2-granian-test-role, roles behind current launch templates) is just a normal Tag Management edit (Phase 20.37) – add Origin=<value> the same way any other tag gets added. A dedicated one-click “tag as DLD-owned” action was considered in the initial pass but dropped once the tag stopped being a clasm-hardcoded Owner=DLD value and became a general, freeform tag with no built-in vocabulary to shortcut.

Browse-list display: the Origin tag’s actual value, or an explicit “(unset)” – not a fixed category enum, not collapsed to editable/ read-only. Each Roles/Instance Profiles/Policies row shows whatever Origin actually holds, filterable via the existing List-tier filter (“/”). This is deliberately IAM-domain-only for v0.0.5 – the same column could extend to the other five taggable kinds (instances, AMIs, launch templates, key pairs, buckets) once the vocabulary’s been proven out here and reacted to by the group, but that’s a later, separate extension: those resources don’t have the same “is this even ours to touch” ambiguity IAM roles do, so the need there is weaker today.

Tag Management domain extension. tagManagementKinds (currently Instance/AMI/Launch Template/Key Pair/S3 Bucket) gains three more entries – IAM Role, IAM Instance Profile, IAM Policy – using the same generalized tagApplyFunc-closure pattern the S3 Bucket slice established (Phase 20.30), so full add/update/remove/show-all-tags works on IAM resources through the existing domain, not a new mechanism. This directly answers the requirement that profiles, policies, and roles must be taggable in clasm, not just readable – and is also how Origin itself gets set, since there is no separate, dedicated action for it.

Detail view. Picking a role or instance profile shows: trust policy (which principals can assume it), attached managed + inline policies (with a way to view the policy document – raw JSON vs. a summarized rendering left for the implementation plan), all tags, SSM-capability (reusing instanceProfileIsSSMCapable/roleHasSSMPermissions, Phase 20.33), and a best-effort cross-reference to instance profiles/running instances currently using it (via DescribeIamInstanceProfileAssociations, already used by Phase 20.33’s associate/replace workflow).

Role/policy creation: curated per-use-case templates, reversing the 2026-07-02 “never creates a role, only attaches an existing one” scope (DECISIONS.md, “Support picking or creating an IAM instance profile from within awsops”) – deliberately, and only through curated templates rather than free-form policy authoring. Each template is a parametrized statement set: the operator supplies specific ARNs at creation time (a bucket name, a distribution ID, a secret name) rather than clasm guessing account specifics. Trust principal is EC2 only for now, modeled as a small TrustPrincipal enum/type from the start so Lambda/ECS-task principals can be added later without reshaping the creation flow – this team isn’t making heavy use of either today, but some of the use cases below are plausible future serverless candidates. If the config’s DLD-match value (see “Categorization,” above) is set at creation time, the newly-created role is tagged Origin=<that value> automatically; if unset, it’s left untagged, same as anything else clasm hasn’t been told how to categorize yet.

Five templates, drafted from the motivating use cases (not from existing policy documents – no reference documents were available), all flagged as needing review before implementation:

Template Draft least-privilege shape
Static Website (S3 + CloudFront) s3:GetObject/s3:ListBucket on one bucket ARN; optionally s3:PutObject/s3:DeleteObject + cloudfront:CreateInvalidation scoped to one distribution ARN, if the role is for a publish process rather than read-only serving
RDM Repository Instance AmazonSSMManagedInstanceCore (already enforced at launch, Phase 20.33) + scoped S3 read/write on one backup-bucket ARN – directly closes the gap that caused the 2026-07-22 granian-testing incident (a running instance needing S3 access with no instance-role option available, falling back to a plain IAM user access key)
Bridge Service (internal systems) Baseline only: SSM + CloudWatch Logs. Too varied across actual services to template further; flagged in the picker as “starting point, review before use”
Patron-Facing Service SSM + CloudWatch Logs + optional Secrets Manager read (secretsmanager:GetSecretValue) scoped to one secret ARN + optional S3 read on one bucket
Data Processing (metadata extraction/curation) SSM + CloudWatch Logs + S3 read/write on one data-bucket ARN

The three thinnest templates (Bridge Service, Patron-Facing, Data Processing) are accepted as v0.0.5 starting points, not finished least-privilege policies – refine once real usage surfaces what these services actually need, not before.

Non-goal, unchanged from the rest of this project’s IAM scope: clasm never creates, modifies, or deletes IAM users. This addendum is scoped entirely to roles, instance profiles, and policies.

New Configuration: origin_tag

Following the existing regions/backup_directories pattern (see “Configuration,” above): a new, optional YAML section in ~/.clasm.

origin_tag:
  key: "Origin"
  dld_value: ""

Not decided yet

Exact IAMAPI surface additions (TagRole, TagInstanceProfile, TagPolicy, ListRoleTags, ListInstanceProfileTags, ListPolicyTags, CreateRole, CreatePolicy, AttachRolePolicy, ListPolicies, GetRole, GetPolicy/GetPolicyVersion, etc.); whether Origin is the right default tag-key name to propose in a demo, given the real vocabulary is still pending group feedback (easy to change later, since it’s a config default, not hardcoded); whether the three thin templates should visually warn differently from the two more fully-scoped ones in the picker UI; raw JSON vs. summarized rendering for policy documents in the detail view; how deletion of a role/profile/ policy should be gated (the existing Environment=production + type-to-confirm tier, Feature 9, or a new gate specific to IAM given the blast radius of a bad delete) – all left for the implementation plan.

CRUD completion for DLD-owned roles (Design Addendum, 2026-07-23, PLAN.md Phase 20.40)

Status: designed and implemented 2026-07-23, same day as the rest of this domain – motivated directly by live use: testing the Phase 20.39 creation templates left behind test roles (test-rdm-repo-role, test-static-site-role, etc.) with no way to remove them from within clasm. See DECISIONS.md, “IAM Profile & Role Management: support CRUD for DLD-owned roles,” for the three scoping decisions (delete + attach/detach, not just delete; cascade-delete the dedicated policy; type-to-confirm gate).

Delete Role. Pick a DLD-owned role (the picker is filtered to DLD-owned roles only – there’s no legitimate reason to offer deleting a role clasm doesn’t recognize as DLD’s), see its full detail (reusing Phase 20.38’s detail view), and delete it. Refuses upfront, before any confirmation prompt, if the role is still referenced by an instance profile – detaching a role from an instance profile is already Phase 20.33’s own “Associate/replace IAM instance profile” action, and automating that cross-cutting side effect here would be scope creep into a workflow that already exists. Order follows AWS’s own documented precondition list for DeleteRole: delete inline policies (DeleteRolePolicy), detach managed policies (DetachRolePolicy), then delete the role. If the role has a dedicated customer-managed policy created alongside it by a Phase 20.39 template (named <role>-policy, matching createIAMRoleFromTemplate’s own naming convention), that policy is deleted too, but only if nothing else still uses it (checked via ListEntitiesForPolicy, not assumed from the naming convention alone) – this avoids leaving an orphaned policy as new clutter, the same problem this feature exists to solve. Gated behind ConfirmDestructive (type-to-confirm the role’s own name), matching this project’s existing destructive-operation tier (Terminate Instance, Remove AMI) rather than a plain yes/no – deleting a role is irreversible and the blast radius (anything still assuming it) isn’t always fully visible to the operator.

Attach Policy to Role / Detach Policy from Role. Pick a DLD-owned role, then either pick any customer-managed policy to attach (not filtered to DLD-owned policies – attaching an IMSS- or AWS-authored policy to a DLD-owned role is a legitimate, expected case) or pick one of the role’s currently-attached policies to detach. Both gated behind a plain Confirm, not ConfirmDestructive – unlike deleting a role, attaching or detaching a single policy is trivially reversible via the paired action. Detaching a policy never also deletes it (unlike Delete Role’s cascade) – that cascade is specific to Delete Role’s own dedicated-policy convention, not a general rule, since a detached policy may still be in use elsewhere or may never have been clasm’s to manage in the first place.

Instance/AMI Detail Views (Design Addendum, 2026-07-24, PLAN.md Phase 20.41)

Status: designed 2026-07-24, targeted for v0.0.5, motivated by TODO.md’s outstanding “show instance/AMI details” item – Show Launch Template (Phase 20.27/20.28) already gives this shape of single-resource detail view for launch templates; instances and AMIs are still only ever seen as a row in the List-tier table (Show instances/Show AMIs), never a dedicated detail screen.

Two new Compute-domain menu entries, Show instance detail and Show AMI detail, appended at the end of mainMenuItems (after “Archive stale backups to S3 and trim disk space”) rather than placed near the existing “Show instances”/“Show AMIs” List-tier entries – appending, not reordering, is this project’s established convention for adding a menu entry without invalidating existing numeric-index tests (see Phase 20.40’s own placement rationale for its three new IAM actions).

Each workflow: pick one resource (reusing the existing Picker-tier pickInstance/pickImage), then run a single-resource AWS describe call for the fuller field set – not adding these fields to the aggregate Instance/Image structs ListInstances/ListImages already populate for every other call site (the list views, pickers, Tag Management, etc.), since most of those call sites have no use for them and every additional field means one more thing every existing caller has to construct in tests. This mirrors DescribeLaunchTemplateVersion’s own separate, on-demand shape (show_launch_template.go) rather than growing inventory.Instance/ inventory.Image themselves.

Show instance detail (new inventory.DescribeInstanceDetail, one ec2:DescribeInstances call scoped to a single instance ID) displays: instance ID/name, state, instance type, AMI ID, region, VPC ID, subnet ID, security group IDs, IAM instance profile (if any), key pair, public/private IP, root + attached EBS volume sizes (reusing GatherVolumeInfo, already used at AMI-creation time), Project/ Environment, and the full tag set.

Show AMI detail (new inventory.DescribeImageDetail, one ec2:DescribeImages call scoped to a single image ID) displays: AMI ID/name, creation date, region, architecture, ENA support, root device name, block device mappings (device name, volume size, snapshot ID per mapping), Project/Environment, and the full tag set.

Both are read-only – no new AWS write calls, no new confirmation gating.

Not decided yet

Exact display formatting/field ordering (left to match displayLaunchTemplateVersion’s existing curated-field-list style); whether a not-yet-attached/terminated instance’s missing fields (e.g. no public IP) render as “none” vs “unknown” – follow the existing displayOrNone convention used elsewhere unless a real case argues otherwise.

Configure clasm Domain (Design Addendum, 2026-07-24, PLAN.md Phase 20.42)

Status: designed 2026-07-24, targeted for v0.0.5. Motivated directly by the user’s request: ~/.clasm’s regions/backup_directories/ origin_tag settings are currently hand-edited YAML only (see “Configuration,” above) – no in-tool way to view or change them.

A sixth Domain Picker entry, “Configuration” – alongside Compute/Key Management/S3/Tag Management/IAM – matching Tag Management’s and IAM’s own precedent of a new domain for a cross-cutting concern rather than nesting it inside an existing one (this settings editor doesn’t belong to Compute, S3, or IAM specifically).

Edits happen against an in-memory copy of config.Config, not the file directly. The domain loop (same loop-until-‘q’ shape as Tag Management, Phase 20.29) holds a working copy loaded once on entry; every edit action below mutates only that copy. Nothing touches ~/.clasm until the operator explicitly picks Save. Pressing ‘q’ with unsaved changes pending shows a plain warning (“Unsaved changes will be discarded”) rather than silently writing or silently discarding – consistent with this project’s general caution around persisting state without an explicit, visible action.

Menu actions: - Show current config – prints the working copy’s regions, backup directory rules, and origin tag settings in the same curated-field style as other Show actions. - Edit regions – list current regions; add one (free-text, no live validation against AWS’s actual region list – a typo’d region simply won’t resolve to a usable client next launch, the same failure mode as hand-editing the YAML today); remove one via a picker. Flags directly in the UI that region changes only take effect on clasm’s next launch – cmd/clasm/main.go builds the region-to-client map once at startup from the config loaded at that time, so a live-session change can’t retroactively add/remove a running client. - Edit backup directory rules – list current pattern -> directory rules in order; add one (prompts for pattern then directory, appended to the end – first-match-wins order, per config.BackupDirectoryFor, so append is the safe default; reordering is not exposed in v1 of this domain, since nothing today needs it yet); remove one via a picker. - Edit Origin tag config – edit key (defaults "Origin") and dld_value (defaults "") as two plain prompts, pre-filled with the working copy’s current values – same shape as the IAM domain’s existing dependency on this setting. - Save – calls new config.Save(path, cfg) error (YAML-marshal the working copy, write to the resolved config path – config.DefaultPath() unless -config was passed) and refreshes the working copy’s “clean” state so a subsequent ‘q’ doesn’t warn.

New config.Save. No existing write path in internal/config today (Load only). Save marshals the whole Config struct via yaml.Marshal and writes it with 0644 (no secrets live in this file, unlike the 0600 private-key convention create_key_pair.go uses) – round-trips through yaml.v3, so any hand-written comments in an existing ~/.clasm are lost on first Save from within clasm. This is called out directly in the UI’s Save confirmation text so it isn’t a silent surprise for anyone who has hand-annotated their file.

Not decided yet

Whether region add/remove should validate against AWS’s actual published region list (would need a hardcoded list to stay offline-friendly, since there’s no “list all regions” call that doesn’t itself need a client already configured) – left as free-text for v0.0.5; exact prompt copy for the Save confirmation and the unsaved-changes-on-‘q’ warning, left for the implementation plan.

User-Data Pre-Flight Size Check (Design Addendum, 2026-07-28, PLAN.md Phase 20.44)

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28. Motivated directly by a recurrence of InvalidUserData.Malformed: User data is limited to 16384 bytes, this time syncing granian-rdm-v14/cloud-init.yaml (57849 raw bytes) to a launch template – confirmed via clasm’s own --debug JSONL log that Phase 20.34’s gzip fix (encodeUserData, userdata_gzip.go) is necessary but not sufficient: the file gzips (default gzip.NewWriter, compression level 6) to 16671 bytes, 287 over the limit, and even gzip -9/gzip.BestCompression only reaches 16576 – still 192 over. encodeUserData has never validated size against AWS’s 16384-byte limit at any of its three write sites (Launch, buildRequestLaunchTemplateData, createLaunchTemplateVersion) – the limit is only ever mentioned in a source comment. The operator’s only signal today is AWS’s bare 400, with no clasm-side context on how far over the limit the payload is or which of the (currently invisible) compressed bytes is responsible.

Two changes, both scoped to userdata_gzip.go and its three call sites:

  1. Switch encodeUserData to gzip.BestCompression (from the implicit default, level 6). Same reasoning Phase 20.34 already applied to “always gzip, never conditionally” – there’s no behavioral reason to leave headroom on the table, and this is a one-line change with no downside (compression time is not a user-visible cost at these file sizes). Not sufficient alone for this incident’s file (confirmed above), but narrows the gap for files close to the limit.
  2. A hard pre-flight size check inside encodeUserData itself, after gzip-compressing and before base64-encoding. New maxUserDataBytes = 16384 constant (matching AWS’s own documented limit, no longer just a source comment); if the compressed byte count exceeds it, encodeUserData returns an error stating the compressed size and how far over the limit it is, instead of proceeding to a doomed AWS call. This requires widening encodeUserData’s signature to (string, error) – matching decodeUserData’s existing shape – and updating its three call sites (launch_execute.go, launch_template_create.go, launch_template_sync.go) to propagate the error.

On failure: hard error, abort (user’s explicit scoping call, 2026-07-28) – matches this codebase’s established “fail loud, don’t guess” precedent (growRootFilesystem’s SSM/layout-detection fallback). The operator sees exactly how many bytes over the limit the compressed payload is and trims the cloud-init file themselves (e.g. moving package installs or large embedded scripts to a separate mechanism fetched at boot) before retrying the whole workflow – no inline “pick a different file” loop-back, since the failure surfaces deep in the call chain (inside encodeUserData, called from createLaunchTemplateVersion/buildRequestLaunchTemplateData/ Launch), well past where the file was originally selected via promptCloudInitYAMLFile.

Rejected alternative. A multipart/#include/S3-reference mechanism to split a payload that’s fundamentally too large even at max compression – this is a real, standard cloud-init/EC2 pattern (store the bulk of the content in S3, have a small stub user-data #include it at boot), but it’s a materially larger feature (new upload step, new IAM/S3 permissions surface, a new read-path for decode/diff/show) that wasn’t requested and has no second use case yet – the actual 2026-07-28 incident was resolved by trimming the cloud-init content itself, outside clasm. Noted here, not designed, as a candidate if oversized cloud-init files recur after this pre-flight check ships (see TODO.md’s Someday/maybe).

Not decided yet

Exact wording of the pre-flight error message beyond “states the compressed size and the overage” – left for the implementation plan.

Status: designed, implemented, unit-tested, and verified in a real interactive terminal 2026-07-28. Motivated by TODO.md’s own bug report: “When selecting a cloud init, there is no obvious exit, ‘q’ is treated as a filename.” Investigating it surfaced two distinct, compounding problems, both in the same area of code.

Bug 1 – no discoverable single-step cancel on promptCloudInitYAMLFile (userdata.go). Every Select-based picker in this app (pickString/pickComparable) shows a hintCancel-style “(q to cancel)” hint and binds ‘q’ as a Quit key alongside Ctrl+C (menuQuitKeyMap). promptCloudInitYAMLFile is a free-text huh.Input field (via ui.Prompt), which has no such binding – confirmed by reading huh’s own InputKeyMap (keymap.go), which only defines Next/Prev/Submit/AcceptSuggestion, nothing for Quit. An operator trained by every other picker in the app types “q”, which is simply accepted as literal text, then fails to read as a file and re-prompts forever with no hint that anything else would work.

Bug 2 – found while tracing where a cancel signal actually goes: SyncLaunchTemplate (launch_template_sync.go) never wraps its testable core’s return value with cancelledIsNil. Every sibling entry point that calls promptCloudInitYAMLFile (CreateInstanceFromCloudInit, CreateLaunchTemplateFromCloudInit) wraps it: return cancelledIsNil(w, err). SyncLaunchTemplate instead does return syncLaunchTemplate(ctx, w, clients, lt, nil, nil) – whatever syncLaunchTemplate returns (including a cancellation from promptLaunchTemplateVersion or promptCloudInitYAMLFile) propagates raw up to runMainMenu’s dispatch. There, isExitSignal – the fallback for a cancel signal that escaped an inner cancelledIsNil – catches it and exits the entire clasm program, not just the “Sync cloud-init YAML to a launch template” action. This is the exact bug class already found and fixed once before in this codebase (v0.0.3, backup_archive.go’s bucket-picker cancellation, DECISIONS.md/TODO.md) – it recurred here because the fix was never generalized into a check across every call site that skips straight to a raw return.

Decision: fix both, since they compound on the same reported symptom.

  1. promptCloudInitYAMLFile’s label becomes "Cloud-init YAML file path (q to cancel)"; a bare "q" (exact match, checked before the existing "@"-prefix stripping so an explicit "@q" still means “a file literally named q”) returns ui.ErrCancelled instead of attempting to read a file named “q”. Accepted trade-off: a real cloud-init file literally named q (no extension) can no longer be referenced bare – prefixing it with "@" still works, and no file like this has ever come up in this project’s actual use of cloud-init YAML (always *.yaml/*.yml, always via @path or a bare path already, per loadUserData’s own docs).
  2. SyncLaunchTemplate wraps its call the same way its siblings already do: return cancelledIsNil(w, syncLaunchTemplate(ctx, w, clients, lt, nil, nil)). This also fixes cancelling at the promptLaunchTemplateVersion step (the version-to-sync-against prompt, one step before the file picker) for free – same root cause, same fix.

Rejected alternative. Add a generic wrapper/lint check across every MenuActions entry point to catch a missing cancelledIsNil mechanically – worth wanting, but a much larger sweep (would need to audit all ~30 entries) than this narrowly-reported bug calls for; noted here as a candidate for a dedicated pass later, not undertaken now.

Not decided yet

None – this is a small, fully-scoped fix.

Modify Launch Template Size (Design Addendum, 2026-07-28, PLAN.md Phase 20.46)

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28. Motivated by TODO.md’s requested feature: “Need to be able to modify a launch templates’ size and EBS storage from clasm in addition to syncing the cloud-init.” Today, Sync Cloud-Init YAML to a Launch Template is the only way to create a new launch template version, and it only ever touches UserData – there’s no way to change instance type or EBS root volume size without hand-editing via the AWS Console.

One combined action (user’s explicit call, 2026-07-28): a new “Modify launch template’s instance type / EBS root volume size” entry prompts for both together and creates one new launch template version with both overrides – matches how creation already collects both together, and avoids two near-identical entries that would each need their own version-selection/no-op/confirm scaffolding for half the value.

Flow, mirroring Sync’s own shape (syncLaunchTemplate) closely: pick a launch template (Picker-tier, same pickLaunchTemplate) -> pick a source version to base the change on (same promptLaunchTemplateVersion, pre-filled $Default) -> inventory.DescribeLaunchTemplateVersion to read the version’s current InstanceType/RootVolumeSizeGB/ImageID -> describeImageRootVolume (already exists, ebs_size.go) against that ImageID for the root device name and the AMI’s own snapshot default size -> prompt for a new instance type (pre-filled to the current one via display, not enforced) and a new root volume size (pre-filled to the current override, or the AMI default if the version never set one) -> if neither changed, “No changes – nothing to modify,” same framing Sync uses for identical content -> confirm -> CreateLaunchTemplateVersion (new modifyLaunchTemplateVersion, setting only InstanceType+BlockDeviceMappings in RequestLaunchTemplateData, inheriting everything else – including UserData – via SourceVersion, exactly as createLaunchTemplateVersion already does for the reverse case) -> same “not yet default, use Promote when ready” reminder Sync prints. Never auto-promotes – no reason for this action to behave differently from Sync’s own explicit “the operator expects to experiment with in-progress versions without silently changing what a plain launch picks up” rationale.

Instance type: unfiltered picker (promptInstanceType(w, "", ...)), plus a post-hoc architecture-mismatch check that can swap the AMI. Phase 20.35’s launch-time picker filters by architecture before the pick, because an inventory.Image (carrying Architecture) is already in hand at that point. Here the operator is choosing a new instance type for an existing template – filtering the picker by the template’s current AMI architecture would be actively wrong, since switching architecture families (the user’s own motivating example, 2026-07-28: granian-rdm-v14-test is currently Graviton/arm64, and they want to switch it to an x86_64 type) is a legitimate, expected use of this feature, not an error case to filter away. So the picker stays unfiltered (also covers “Other,” e.g. m7i-flex.2xlarge, which isn’t in curatedInstanceTypes – the user confirmed this can stay reachable via “Other” rather than being added to the curated list).

AMI-swap on architecture mismatch (added 2026-07-28, after the Graviton -> x86_64 example surfaced a real gap in the original, narrower design): an EC2 instance type’s architecture is tied to the AMI it launches from – an arm64 AMI cannot boot as an x86_64 instance type and vice versa, and CreateLaunchTemplateVersion does not validate this at creation time, only RunInstances does, later, when someone actually launches from the template. Without an AMI swap, the narrower instance-type + EBS-size-only design would let an operator create a new version that looks fine and then fails at the next actual launch. Fixed by checking after the instance type is picked: a new instanceTypeArchitecture helper (ec2:DescribeInstanceTypes, mirroring instanceTypeRequiresENA’s exact shape/API) reports the picked type’s required architecture; if it doesn’t match the current AMI’s architecture (from describeImageRootVolume, widened to also return it – already reading the same DescribeImages response, no extra call), the operator is shown why and prompted to pick a new base AMI, filtered to the target architecture and the launch template’s own region (a launch template’s AMI must exist in that same region; imagesWithOfficialUbuntu(ctx, clients, images)’s combined multi-region list is filtered down before the pick). If no compatible AMI exists in that region, this fails loud with an actionable error rather than handing the picker an empty list. Root device name and the AMI-default floor are then re-fetched against the new AMI (different AMIs can have different root device names, e.g. /dev/sda1 vs /dev/xvda, and different default sizes) before the EBS-size prompt. The new version’s RequestLaunchTemplateData always explicitly sets ImageId (not just InstanceType+BlockDeviceMappings) – harmless when unchanged (same value the version already had), and what actually makes the swap take effect when it did change.

Testability seam for the conditional AMI re-pick: pickImage runs a real bubbletea tui.RunPicker program, the same “can’t be pipe-tested” limitation every other Picker-tier call in this package already has – but every other one of those is hoisted to the very start of an otherwise-untestable exported entry point, with the testable core taking the already-resolved value as a plain parameter. This one is different: it’s conditional, invoked partway through an otherwise fully pipe-testable prompt sequence (after the instance-type prompt, before the EBS-size prompt), so it can’t be hoisted out the same way. Given a package-level func-var seam instead (pickNewLaunchTemplateAMIFunc), the same shape backup_archive.go’s promptBackupBucketFunc already established for a conditional/hard-to-drive step embedded inside an otherwise testable sequence – tests substitute a fake and restore the real one after.

EBS size: shrinking is allowed down to the AMI’s own snapshot size (user’s explicit call, 2026-07-28) – not floored at whatever the template currently specifies. AWS’s only real constraint on a launch template’s BlockDeviceMappings.Ebs.VolumeSize is never smaller than the source snapshot, the same floor promptRootVolumeSizeGB already enforces at creation time; there’s no live-attached-volume restriction here the way ResizeInstanceRootVolume has (that one really can only grow, an AWS API-level restriction on ec2:ModifyVolume for a running volume – a launch template has no such limit). promptRootVolumeSizeGB widens from one defaultGB parameter (used for both the pre-filled display value and the validation floor – identical at creation time, since there’s no “current template setting” yet) to two: displayDefaultGB (what’s shown pre-filled – the version’s current override, or the AMI default if it never set one) and floorGB (always the AMI’s own snapshot size). The two existing creation-time call sites pass the same value for both, unchanged behavior.

Not decided yet

Whether m7i-family (or other non-curated) instance types should be added to curatedInstanceTypes – raised by the user while confirming scope, not part of this phase; “Other” already covers typing any exact value, curated-list expansion is a separate, smaller follow-up if wanted.

SSH Connection Info: Key Path + Username Guess (Design Addendum, 2026-07-28, PLAN.md Phase 20.47)

Status: designed, implemented, unit-tested, and real-AWS-verified 2026-07-28. Motivated by TODO.md’s requested feature: “show the SSH command to SSH in using the keys created and associated with the instance.” displayConnectionInfo (launch_execute.go, shared by Create Instance from AMI/Cloud-Init YAML, Create Instance from Launch Template, and Start Instance) already prints a ssh ec2-user@<ip> line – but with two real gaps: no -i <key path> at all, and “ec2-user” hardcoded even though every AMI this tool curates/offers by default (the official Ubuntu list) uses “ubuntu,” not “ec2-user.”

Key path: guessed from sshKeyDir() (~/.ssh) + the instance’s own key pair name + .pem, shown only if that exact file exists on disk. This matches exactly where createKeyPair saves a newly created key pair’s private key material – but an imported key pair (keypair_import.go) only ever registers a .pub file with AWS; the private key could be anywhere (e.g. ~/.ssh/id_ed25519, no .pem, possibly not even named after the AWS key pair), so guessing wrong and presenting it confidently would be worse than not guessing. os.Stat confirms the guessed path is real before showing it; otherwise the line names the key pair and says the path is unknown, rather than printing a command that will fail with a confusing “Permission denied (publickey)” if copy-pasted as-is.

Username: “ubuntu” if the launched AMI is Canonical-owned (OwnerId == ubuntuAMIOwnerID, the same well-known account ID official_ubuntu_amis.go already uses to fetch the curated list), else “ec2-user.” A live, best-effort ec2:DescribeImages call (new sshUsernameForImage) – checking OwnerId covers every Canonical Ubuntu AMI, not just the specific curated releases in curatedUbuntuReleases, and works uniformly across all three call sites (including Create Instance from Launch Template, which never has an inventory.Image in hand at all – only the template’s stored ImageId). Falls back to “ec2-user” (today’s existing default, correct for Amazon Linux/RHEL-family) if the check errors or the AMI isn’t Canonical-owned – there’s no reliable API-level way to identify every other distribution’s own default login user, and “ec2-user” was already the assumption every one of today’s callers made.

No new parameters threaded through the three call sites. Both KeyName and ImageId are already present directly on the raw SDK types.Instance (inst) every caller already has in hand from WaitUntilRunning/its own DescribeInstances call – confirmed via inventory.instanceFromSDK’s own existing aws.ToString(inst.KeyName)/ aws.ToString(inst.ImageId) reads of the same type. Only displayConnectionInfo’s own signature widens (ctx, an awsclient.EC2API, for the live DescribeImages call), not LaunchInstanceParams or any exported workflow signature.

Not decided yet

None – this is a small, fully-scoped fix.

Core Features

Compute Domain (EC2 & AMI)

Features 1 through 12 below are unchanged in behavior from the original single-menu design — only their position in the navigation changes (see “Navigation: Domain Picker” above).

1. Unified Resource Listing

On startup, the tool fetches and displays: - All EC2 instances across the configured regions - For each instance: ID, Name (from tags), State, AMI ID, Region, Project and Environment (from tags — see “Project/Environment Tagging” below; shown as “unknown” if untagged), and Public/Private IP (shown as “none” if the instance has neither, e.g. stopped or launched without a public IP — see DECISIONS.md, “Show instance IP addresses in the main listing”; this is what makes it possible to look up which instance to ssh into without a separate lookup step) - All AMIs owned by the current AWS account across the configured regions - For each AMI: ID, Name, Creation Date, Region, Project and Environment - Listing can be grouped/filtered by Project and by Environment, so “show me everything for caltechauthors” or “show me only production” is a quick operation instead of scanning a flat list by name

2. Create EC2 Instance from AMI

Interactive workflow: 1. Display pick list of available AMIs — the account’s own AMIs (owned- by-account, as before) plus a short curated list of official Ubuntu LTS releases (currently 24.04 and 22.04, amd64 only), so launching from a fresh base image doesn’t require first copying a public AMI into the account by hand. See DECISIONS.md, “Offer official Ubuntu LTS AMIs alongside owned AMIs when picking a base AMI” — this is a curated addition, not a general public-AMI browser; anything more exotic (arm64/Graviton, a different distribution, a specific non-LTS release) still means copying that specific public AMI into the account first, same as before this addition. 2. User selects an AMI 3. Prompt for required parameters: - Instance type: a pick list of a curated shortlist relevant to this team’s actual usage (t3/m5/c5/r5 family, plus t2.micro/t2.medium — the list’s only non-Nitro, no-ENA-required entries, included after real-world use hit a legacy AMI no ENA-requiring type could ever launch; ~11 entries total), each labeled with vCPU/memory, plus “Other” to type any value not listed — not a full AWS catalog listing (600+ types per region), which would reproduce the “flat list is noise, not help” problem already found with key pairs at a much larger scale. See DECISIONS.md, “Instance type pick list: curated shortlist, not the full AWS catalog” and “Add non-ENA- required options to the curated instance type list”. Once picked, checked against the AMI’s ENA support and (after Subnet ID, below) the subnet’s Availability Zone — see those two items below and DECISIONS.md, “Pre-flight check: instance type vs. AMI ENA support” / “… vs. subnet Availability Zone” - Key pair name: a pick list of key pairs that actually exist in the AMI’s region (ec2:DescribeKeyPairs), plus “Create new key pair” (which calls ec2:CreateKeyPair, saving the private key to ~/.ssh/<name>.pem with 0600 permissions; see “Debug Logging” below for why its response is handled specially in the -debug log). Unlike Security group IDs/Subnet ID, there’s no “Other: type a name” escape hatch — key pairs are a complete, small, fully-enumerable list per region, so a name outside it is guaranteed not to work there. Falls back entirely to the original free-text prompt (typing new, or a private key filename/path — /, ~, or .pem/.ppk/.key — validated as readable and resolved to its AWS name, since ssh -i muscle memory makes typing the file a real, recurring mistake) only if the list itself can’t be fetched. See DECISIONS.md, “Validate key pair name against the AMI’s region” and “Derive the AWS key pair name from a private key filename/path”. - Security group IDs (list available security groups) - Subnet ID (list available subnets, narrowed to Availability Zones that actually support the instance type chosen earlier — ec2:DescribeInstanceTypeOfferings — so an incompatible subnet is never offered in the first place; falls back to the full, unfiltered list if that lookup fails or would filter to nothing. See DECISIONS.md, “Filter the subnet picker by instance-type Availability Zone support”). As a safety net for whatever this filtering can’t cover (lookup failure, free-text fallback with an unknown AZ), the picked subnet is still checked against the instance type after the fact — if AWS still wouldn’t accept the pairing, a pick list offers to change the instance type, pick a different subnet, or abort the launch, instead of either a dead end or a doomed RunInstances call. See DECISIONS.md, “Pre-flight check: instance type vs. subnet Availability Zone”. - IAM instance profile (optional; list available instance profiles via iam:ListInstanceProfiles, offering “(none)” to skip and “Create new instance profile (attach an existing role)” to create one on the spot — pick a role via iam:ListRoles, then iam:CreateInstanceProfile + iam:AddRoleToInstanceProfile; falls back to a free-text prompt only if the list itself can’t be fetched. See DECISIONS.md, “Support picking or creating an IAM instance profile from within awsops” — this replaced an earlier free-text-only prompt that pointed at “IAM console > Roles,” which real-AWS testing showed leads operators to type a role name where AWS actually expects the (often differently named) instance profile name, producing AWS’s own “Invalid IAM Instance Profile name” error) - User data (optional) — a cloud-init YAML or any other user-data script, entered inline or loaded from a local file path (e.g. pointing at a file from a local clone of cloud-init-examples), prefixed with @ (e.g. @newt-machine.yaml). A bare filename typed without the @ (a real mistake found in use) is auto- detected and loaded anyway if a file actually exists at that path — a bare filename is never valid literal user-data, so silently using it as inline text would launch the instance with that string as its user-data instead of the file’s contents. See DECISIONS.md, “Auto-detect a bare existing-file path in User data / Cloud-init YAML input”. - Tags — Name (required), Project and Environment (suggested; see “Project/Environment Tagging Convention” below), plus any additional free-form tags 4. Confirm all parameters before launching 5. Launch instance; poll until running — tolerates AWS’s own brief post-launch InvalidInstanceID.NotFound window (the new instance ID isn’t always immediately visible to DescribeInstances) rather than treating it as a failure; see DECISIONS.md, “Tolerate DescribeInstances’ post-RunInstances eventual-consistency window” 6. If user-data was provided: wait for SSM to report Online and run cloud-init status --wait (bounded timeout — see DECISIONS.md, “Enhance Create Instance from AMI: cloud-init file input + completion check”), reporting cloud-init’s actual completion status (done vs error), not just that the instance reached running. If SSM never comes online, skip this check cleanly (not an error) — not every AMI has SSM configured. Tolerates AWS’s own brief post-SendCommand InvocationDoesNotExist window the same way step 5 tolerates DescribeInstances’ post-RunInstances window; see DECISIONS.md, “Tolerate GetCommandInvocation’s post-SendCommand eventual-consistency window” 7. Display connection info (public/private IP, SSH command)

See also: Feature 3 shares this exact execution path but leads with the cloud-init file as the primary input (pick a base AMI second) rather than treating user-data as one optional parameter among several.

3. Create EC2 Instance from Cloud-Init YAML

Interactive workflow (see DECISIONS.md, “Add Create EC2 Instance from Cloud-Init YAML as a v1 primitive”). Shares its underlying execution with Feature 2 entirely — same launch/poll/cloud-init-completion-check logic — but a different entry point: the cloud-init file is the primary input, not one optional parameter among several. 1. Prompt for a cloud-init YAML file path — unlike Feature 2’s optional “User data” field, this prompt always reads from disk rather than also accepting inline text: real cloud-init YAML is realistically always a file (e.g. from a local clone of cloud-init-examples), never something typed inline at a terminal. A leading @ is tolerated (muscle memory from Feature 2’s prompt) but not required. Re-prompts on a missing/unreadable file rather than silently using the value as literal text — see DECISIONS.md, “Create EC2 Instance from Cloud-Init YAML always reads from a file” 2. Pick a base AMI to launch it on (same pick list as Feature 2) 3. Collect the remaining launch parameters — instance type, key pair, security groups, subnet, IAM profile, tags — identical to Feature 2 4. Confirm all parameters before launching 5. Launch; poll until running 6. Wait for SSM Online and run cloud-init status --wait (bounded timeout), reporting completion status (done vs error) — same mechanism as Feature 2’s step 6 7. Display connection info

Not to be confused with the deferred “Bake AMI from cloud-init” idea (which snapshots the result into a new AMI and terminates the instance) — this primitive leaves a real, running, usable instance.

4. Start EC2 Instance

Interactive workflow: 1. Pick a stopped instance 2. Confirm (simple yes/no — starting is safe and reversible, the symmetric counterpart to Feature 5) 3. Call ec2:StartInstances 4. Poll until running (bounded timeout) 5. Display connection info (public/private IP, SSH command) — a restarted instance’s public IP may have changed unless it uses an Elastic IP

5. Stop EC2 Instance

Interactive workflow: 1. Pick a running instance 2. Confirm (simple yes/no — stopping is reversible; data on EBS volumes persists and the instance can be started again) 3. Call ec2:StopInstances 4. Poll until stopped (bounded timeout)

6. Terminate EC2 Instance

Safety-first workflow — same tier as Feature 9 (Remove AMI), since termination is permanent: 1. Pick an instance 2. Dry-run first: show what would be destroyed, including whether any attached EBS volume has DeleteOnTermination=true — that volume’s data (potentially including not-yet-archived backups; see Feature 11) is destroyed along with the instance, not just the instance itself 3. Environment check: if tagged Environment=production, show an additional, explicit warning before type-to-confirm 4. Type to confirm: user must type the instance ID or name exactly 5. Execute via ec2:TerminateInstances 6. Confirm successful termination

7. Manage Tags

A general-purpose action, distinct from the Project/Environment Tagging Convention (Feature 12) below — see “Manage Tags vs. the Tagging Convention” at the end of this feature. Interactive workflow (see DECISIONS.md, “Broaden Rename Instance into a general Manage Tags primitive”): 1. Pick a resource — an instance or an AMI 2. Display its current tags 3. Choose an action: - Add: prompt for a new key and value - Update: pick an existing key from the list, prompt for a new value - Remove: pick an existing key from the list 4. Confirm (a simple yes/no — this is cheap and reversible, unlike Feature 9’s destructive dry-run/type-to-confirm tier) 5. Call ec2:CreateTags (add/update) or ec2:DeleteTags (remove)

Renaming an instance is simply updating its Name tag through this same flow — there is no separate “rename” operation. This does not apply to an AMI’s Name attribute itself, which cannot be changed via the AWS API once set (see the note under Feature 8 above) — Manage Tags only ever touches tags, never that attribute. Editing the Environment tag specifically is worth a brief on-screen note that it’s the same tag used elsewhere in this tool to gate production-safety warnings.

Manage Tags vs. the Tagging Convention: Manage Tags is the general mechanism — it edits any tag key, any time, on demand. The Project/Environment Tagging Convention (Feature 12) is the policy that gives two specific tag keys meaning elsewhere in this tool (defaults during creation, safety gates during destruction). Manage Tags is how you’d edit an Environment tag after the fact; the Convention is why Environment matters to this tool in the first place.

8. Create AMI from EC2 Instance

Interactive workflow, including capabilities ported from ami_copy.bash (see DECISIONS.md, 2026-06-30 “AMI-from-instance: fold ami_copy.bash capabilities into Phase 5”) from day one rather than as a later addition: 1. Display pick list of EC2 instances (running or stopped) 2. User selects an instance 3. Gather attached-volume info; show total size and an estimated creation time (see table under “Domain Knowledge Carried Forward” below) 4. If the instance is running, offer an SSM fstrim pass before snapshotting (skip cleanly if SSM is unavailable) and show the Postgres/OpenSearch/Redis/Docker crash-consistency guidance 5. Prompt for: - AMI name (suggested default: <instance-name-or-id>-copy-<date>, user may override) - AMI description (optional) - No-reboot flag (default: false; only offered for running instances) - Tags — Project and Environment default to the source instance’s tags (if present), plus any additional free-form tags 6. Confirm before creating 7. Create AMI, then poll (unbounded — large Invenio RDM volumes can take 20–60+ minutes) until available or failed, displaying elapsed time

Note: an AMI’s Name is immutable via the AWS API once set here — there is no “rename AMI” operation (see DECISIONS.md, “Add Rename Instance as a v1 primitive; AMI Name is immutable”). The default name suggestion above is still offered; make sure it’s right before confirming, since only Description can be changed after the fact.

9. Remove AMI

Safety-first workflow: 1. Display pick list of owned AMIs 2. User selects an AMI 3. Dry-run first: Show what would be deleted 4. Show dependencies: List any instances currently using this AMI 5. Environment check: If the AMI is tagged Environment=production, show an additional, explicit warning before the type-to-confirm step 6. Type to confirm: User must type the AMI ID or name exactly to proceed 7. Execute deletion 8. Confirm successful removal

10. Show/Export Cloud-Init

Interactive workflow for detecting drift between a deployed instance/AMI and the team’s canonical templates in caltechlibrary/cloud-init-examples (see DECISIONS.md, “Add Show/Export Cloud-Init as a v1 primitive”): 1. Pick an instance or an AMI 2. Instance path (free, instant): call ec2:DescribeInstanceAttribute (attribute userData), base64-decode, display. If no user-data was set at launch, say so plainly — not an error 3. AMI path (costs real time/money — explicit confirmation required before proceeding): launch a temporary, disposable instance from the AMI; wait for it to reach running and for SSM to report Online (bounded timeout — this is a diagnostic operation, not core creation, so it fails cleanly rather than polling unboundedly like Feature 8); run an SSM command to read /var/lib/cloud/instance/user-data.txt off disk; decode and display it; always terminate the temporary instance afterward, including if SSM never comes online or the command fails 4. Export: offer to save the decoded YAML to a local file path, for manual comparison against a local clone of cloud-init-examples (no inline fetch-and-diff against the GitHub repo in v1 — see “Deferred to a Later Version” below)

11. Backup Archive & Trim

Interactive workflow for turning today’s manual “log in and delete old backups” chore into a supervised, verified operation (see DECISIONS.md, “Add Backup Archive & Trim as a v1 primitive”). This is a genuinely destructive workflow (it deletes real backup files), so it gets the same safety tier as Feature 9 (Remove AMI): 1. Pick an instance, immediately followed by a command -v aws preflight check on that instance (see DECISIONS.md, “Preflight check: AWS CLI availability before Backup Archive & Trim”) — aborts fast with a clear, actionable error if the AWS CLI isn’t installed, before any further prompt or the dry-run list 2. Prompt for the backup directory, then the S3 bucket, then the age threshold in days — in that order (see DECISIONS.md, “Reorder Backup Archive & Trim’s prompts”: the threshold reads more naturally once both the source directory and destination bucket are already fixed). The instance picker’s cursor and the directory prompt’s default both recall what was actually used last time for this specific instance (~/.clasm_state, an app-managed file distinct from ~/.clasm — see DECISIONS.md, “Recall Backup Archive & Trim’s instance/directory choices per-instance”), taking priority over ~/.clasm’s backup_directories Name-pattern match (see “Configuration” above; e.g. RDM instances default to /opt/rdm_sql_backups, other services to their own directory) when both exist. Either way the directory stays editable and is never silently accepted; no recalled value and no rule match leaves it unset, same as before either mechanism existed. The age threshold itself has no default — always an explicit, deliberate choice. The S3 bucket prompt itself is a filterable pick list of this account’s buckets ('/' to filter by name), plus an “Other” entry to type any bucket name directly — e.g. one outside this account’s own listing (see DECISIONS.md, “Bucket picker for Backup Archive & Trim”); falls back to a plain free-text prompt if the bucket listing itself can’t be fetched. Immediately followed by s3:GetBucketLocation to discover which region the bucket actually lives in (any region, unrelated to the instance’s — see DECISIONS.md, “Resolve a bucket’s actual region before Backup Archive & Trim’s access check”) and then an s3:HeadBucket access check, scoped to that region, that aborts with a clear reason (bucket doesn’t exist, or the operator’s own credentials can’t reach it) before any of the steps below run — see DECISIONS.md, “Preflight check: S3 bucket access before Backup Archive & Trim’s dry-run list” 3. Dry-run list (SSM, read-only): show candidate files matching the age threshold, with size and age, before anything happens 4. Type to confirm before proceeding 5. Upload phase (SSM): the instance uploads each candidate file to s3://<bucket>/<instance-name>/<filename> via its own AWS CLI/credentials — every key namespaced by the source instance’s Name tag (falling back to its instance ID if untagged) so backups from different systems sharing one bucket can’t collide (see DECISIONS.md, “Namespace backup uploads by instance”) — one ssm:SendCommand per file (SSM Run Command cannot bulk-transfer multi-hundred-MB files — see Feature 10’s AMI-path constraint), printing a live “N/M (bytes of total) — OK/FAIL key” line as each file completes rather than a generic heartbeat (see DECISIONS.md, “Per-file upload progress for Backup Archive & Trim”). The remote aws s3 cp runs with --only-show-errors so its own progress-meter output can never fill ssm:GetCommandInvocation’s 24,000-character output cap and truncate away this script’s own OK/FAIL signal on a large file (see DECISIONS.md, “Suppress aws s3 cp’s progress output to avoid truncating the OK/FAIL signal”). Nothing is deleted at this point 6. Independent verification: the tool itself — using its own AWS credentials, not the instance’s self-report — calls s3:HeadObject on every uploaded key and confirms it exists with the expected size 7. Delete phase: a second, separate SSM command tells the instance to delete exactly the tool-verified file list (the instance does not re-derive its own “what’s stale” list, avoiding a time-of-check/time-of-use gap) 8. fstrim to reclaim the freed blocks, then a report of bytes freed and any files that failed verification (left untouched, not deleted)

This primitive requires the target instance’s IAM instance profile to grant s3:PutObject (and likely s3:ListBucket scoped to its prefix) on the destination bucket — a cloud-init/AMI-level change, not something this tool can grant from outside. See “Assumptions” below.

12. Project/Environment Tagging Convention

Not a user-facing menu item, but a cross-cutting policy applied by instance/AMI creation (Features 2, 3, 8) and destructive operations (Features 6, 9) above (see DECISIONS.md, “Introduce a light Project/Environment tagging convention”). See “Manage Tags vs. the Tagging Convention” under Feature 7 above for how this differs from the general-purpose tag-editing primitive. - Project groups resources by RDM application (e.g. caltechauthors, caltechdata, caltechthesis) — the tool suggests the source instance/AMI’s existing value as a default where one exists - Environment is one of production, development, or test — the tool does not guess this from the resource name; it is always an explicit prompt (with no default) so a “production” tag is a deliberate choice - The tool never rewrites tags on resources it didn’t create; existing untagged resources simply display as “unknown” until someone tags them

Key Management Domain

Key pairs are already touched by Compute (Feature 2’s inline “type new” shortcut during instance launch), but were never first-class, listable, deletable resources in their own right. This domain makes them one.

13. List Key Pairs

Resource listing shown when the Key Management domain is entered: for each region, ec2:DescribeKeyPairs, aggregated into one table (Name, Region, Type, Fingerprint or Key ID). This is the listing this domain’s menu sits below, not a separate menu action.

14. Create Key Pair

Interactive workflow — the standalone form of what Feature 2’s inline “type new” shortcut already calls under the hood, so both share one underlying primitive: 1. Prompt for a name (must be unique within its region) 2. Prompt for region (pick list) 3. Call ec2:CreateKeyPair (ED25519, PEM) 4. Save the private key to ~/.ssh/<name>.pem at 0600 5. Confirm success; remind the operator where the private key landed — it is never displayed or logged again (see Security Considerations #9)

A name collision re-prompts for a different name; any other AWS error propagates normally.

15. Import Key Pair

Interactive workflow, for operators who already have a personal or team public key they want registered instead of generating a new one: 1. Prompt for a name (must be unique within its region) 2. Prompt for a local public key file path (.pub) 3. Prompt for region 4. Read and validate the file is a well-formed public key before calling AWS — fail locally with a clear message rather than surfacing AWS’s raw InvalidKeyPair.Format error 5. Call ec2:ImportKeyPair 6. Confirm success

Unlike Create Key Pair, there is no private key material to save — ec2:ImportKeyPair never returns one, since AWS never sees the private half. This is the reverse direction from Create Key Pair: Create is for when AWS generates the pair and hands you the private .pem half; Import is for when you already generated a keypair yourself (e.g. via ssh-keygen) and want AWS to trust its public half. A .pem file from this tool’s own Create Key Pair is a private key and will always be rejected here — the prompt hints at deriving a .pub file from one with ssh-keygen -y -f <private-key> > file.pub if that’s the operator’s actual starting point.

16. Delete Key Pair

Safety-tier workflow, one notch below Terminate/Remove AMI’s dry-run + type-to-confirm tier (deleting a key pair doesn’t destroy running infrastructure the way terminating an instance does, but it does permanently remove AWS’s copy, so a plain yes/no is too casual): 1. Pick a key pair from the list 2. Show dependent instances: list any running/stopped instances launched with this key pair (ec2:DescribeInstances filtered by key-name) — deleting the AWS-side key pair doesn’t affect those instances’ ability to keep running, but it does mean this key pair can no longer be used to launch new ones, worth surfacing first 3. Type to confirm: operator types the key pair name exactly 4. Call ec2:DeleteKeyPair 5. Confirm deletion; remind the operator the local ~/.ssh/<name>.pem file (if one exists, from Feature 14) is untouched — this tool never deletes local files, only the AWS-side registration

S3 Domain (Buckets & Static Websites)

Two use cases live in this domain: browsing/managing buckets generally, and the specific workflow of standing up a static website backed by S3 + CloudFront. Per DECISIONS.md’s “CloudFront + OAC by default for static websites”, the website workflow defaults to CloudFront + Origin Access Control (bucket stays private; CloudFront is the only reader) rather than a public-read bucket policy — see Security Considerations below.

17. List Buckets

Resource listing shown when the S3 domain is entered: s3:ListBuckets, then for each bucket a lightweight s3:GetBucketLocation (region), best-effort s3:GetBucketWebsite (a NoSuchWebsiteConfiguration error just means “not configured,” not a failure) to show whether static website hosting is enabled, and best-effort s3:GetBucketTagging (a NoSuchTagSet error, or simply no Purpose key present, means “untagged” — not a failure) to read the bucket’s Purpose tag (Feature 18) for Feature 21.1’s use — in one table (Name, Region, Static Website, Purpose; untagged shows blank, matching this tool’s existing “blank means untagged” convention for Name).

Revised by “S3 Resource List Display — Paged, Accessible-Compatible” below (designed 2026-07-10, not yet implemented): the data fetch described above is unchanged, but the table is no longer printed automatically on every S3 menu redisplay — only when “Show resource lists” is explicitly chosen — and printing itself becomes paged.

18. Create Bucket

Interactive workflow: 1. Prompt for a bucket name (globally unique; validate against S3’s naming rules locally before calling AWS) 2. Prompt for region 3. Prompt for bucket purpose — a numbered pick list: Website, Backup, Internal — see “Bucket Purpose Tagging Convention” below 4. Call s3:CreateBucket 5. Block public access by default (s3:PutPublicAccessBlock, all four settings on) — an operator who genuinely wants a public bucket must say so explicitly in Feature 19, not get it by omission here 6. Tag the bucket Purpose: website|backup|internal (s3:PutBucketTagging) so Feature 21.1 (Manage Bucket Lifecycle Policies) can recall this choice later without re-asking 7. Confirm creation

Bucket Purpose Tagging Convention

Not an AWS-enforced concept — a Purpose tag this tool applies at creation (steps 3/6 above) and reads later (Feature 21.1) to decide which lifecycle-policy UX to offer: backup gets a simplified guided flow for the two policy shapes this team actually uses repeatedly (expire after N days; transition to cheaper storage after N days); website and internal, or a bucket with no Purpose tag at all (e.g. one created outside awsops), get a fuller generic lifecycle rule editor. Distinct from Feature 10’s unrelated Purpose=cloud-init- extraction tag on temporary EC2 instances — same tag key name, entirely different resource type and meaning, no relationship between the two.

19. Configure Static Website Hosting

Interactive workflow for turning an existing bucket into a website origin: 1. Pick a bucket 2. Prompt for index document (default index.html) and error document (default error.html) 3. Call s3:PutBucketWebsite 4. Access pattern: default and recommended path is CloudFront + Origin Access Control — this step only configures the website document settings on the bucket itself; the bucket’s public-access-block settings from Feature 18 are left untouched (still blocking public access). Phase 20 implements only this default path — the explicit public-read-bucket-policy opt-out mentioned below is deferred until there’s an actual need for it (see DECISIONS.md, “Defer the public-read bucket policy opt-out in Configure Static Website Hosting”). A future opt-out path would require its own explicit confirmation warning that the bucket contents become world-readable directly, independent of CloudFront. 5. Confirm. Feature 24 (Create Distribution, CloudFront domain) doesn’t exist yet as of Phase 20 — until it does, print that CloudFront support isn’t implemented yet instead of literally offering to hand off to it.

20. Sync Local Directory to Bucket

Interactive workflow for publishing a built static site: 1. Pick a bucket 2. Prompt for a local directory path 3. Dry-run diff: compare local files against the bucket’s current objects (by key and size, not a full checksum, to keep this fast) and show what would be uploaded (new/changed) and, separately, what exists in the bucket but not locally (deletion candidates — never deleted silently) 4. Confirm before uploading 5. Upload new/changed files (s3:PutObject, content-type inferred from extension) 6. If step 3 found bucket-only objects, a separate confirm-and-delete step (s3:DeleteObject) — never bundled into the same confirmation as the upload, so “yes” to publishing new content can never accidentally also mean “yes” to deleting something 7. Report a summary: files uploaded, files deleted, bytes transferred

Superseded, implemented 2026-07-09 by Feature 21.2’s interactive file manager (PLAN.md Phase 20.1) — see “S3 Object Management — Interactive File Manager” below. This directory-to-bucket workflow remains a first-class, directly reachable capability (not folded away as a generic case of something else): it’s now the file manager’s dedicated Sync action (S / :sync, 21.6), not the standalone wizard described above — that wizard (bucket_sync.go) is retired; its diff/walk/list logic moved to internal/s3diff, reused rather than reimplemented.

21. Browse/Manage Objects

Interactive workflow for ad-hoc bucket inspection outside the sync flow: 1. Pick a bucket 2. Prompt for an optional key prefix filter (blank lists everything) – added beyond the original spec once this team’s actual bucket usage made it clear a single bucket (e.g. sql-backups.library.caltech.edu) can hold many objects across many per-instance prefixes (see DECISIONS.md, “Add an optional key-prefix filter to Browse/Manage Objects”) 3. List objects (s3:ListObjectsV2 scoped to that prefix, paginated the same way Feature 1’s PickList pagination already handles >50 items) 4. Choose an object; offer to show metadata (size, last-modified, content-type) or delete it 5. Deletion is a plain yes/no per-object confirm — Feature 20’s bulk sync deletion gets the stronger “separate confirm” treatment because it can affect many files at once; a single ad-hoc delete here is lower blast-radius

Superseded, implemented 2026-07-09 by Feature 21.2’s interactive file manager (PLAN.md Phase 20.1) — see “S3 Object Management — Interactive File Manager” below. Single-object browsing, filtering, metadata, and delete are folded into the new screen’s single-pane mode rather than kept as a second, parallel implementation of “filter, pick, act.” The standalone wizard (bucket_browse.go’s BrowseBucketObjects) is retired; only its listBucketObjectsWithPrefix helper remains, still used by Delete Bucket’s empty-bucket check.

21.1. Manage Bucket Lifecycle Policies

Interactive workflow for reviewing, setting, updating, and removing S3 Lifecycle Configuration rules — covers both “transition to cheaper storage after N days” and “delete (expire) after N days” policies (see DECISIONS.md, “Add Manage Bucket Lifecycle Policies”). Numbered 21.1 (inserted after Feature 21 without renumbering CloudFront’s Features 22-26 — the same decimal-insertion convention PLAN.md already uses for Phases, e.g. 15.1-15.26).

The underlying AWS API (s3:GetBucketLifecycleConfiguration / s3:PutBucketLifecycleConfiguration) only supports replacing a bucket’s entire rule set atomically — there is no per-rule add/edit/delete call. This feature presents CRUD over that atomic-replace API: fetch all existing rules, let the operator pick one to edit or remove, or add a new one, then write the complete modified rule set back in one call.

  1. Pick a bucket (from the already-fetched bucket listing, Feature 17)

  2. s3:GetBucketLifecycleConfiguration (a NoSuchLifecycleConfiguration error means “no rules yet,” not a failure) and display existing rules (ID, prefix scope or “whole bucket”, transition(s), expiration)

  3. Branch on the bucket’s Purpose tag (Feature 17/18):

    backup — guided flow:

    • Add a new policy: prompt “Expire objects after how many days? (blank to skip)” and “Transition to cheaper storage after how many days? (blank to skip)”, plus a storage class pick list when a transition day count is given — a curated subset (Standard-IA, Intelligent-Tiering, Glacier Flexible Retrieval, Glacier Deep Archive; see DECISIONS.md for why this subset), not the full AWS storage-class enum
    • Prompt for an optional key prefix (blank = whole bucket), same convention as Feature 21’s browse filter
    • Existing rules can be edited (re-prompt the same questions, defaulted to current values) or removed (plain yes/no confirm)

    internal, website, or an untagged bucket — generic editor:

    • Add a new rule: prompt for a rule ID (must be unique among the bucket’s existing rules), an optional key prefix, zero or more transitions (each: days + storage class from the full AWS storage-class enum, repeat until the operator stops adding), and an optional expiration (days)
    • Edit an existing rule: pick by ID, re-prompt all fields defaulted to current values
    • Remove a rule: pick by ID, plain yes/no confirm
  4. Whichever path was used, write the complete modified rule set via s3:PutBucketLifecycleConfiguration and confirm success

S3 Resource List Display — Paged, Accessible-Compatible (Design Addendum, 2026-07-10)

Superseded 2026-07-10, same day, by “Terminal UI Architecture: Menus, Actions, Lists, and Managers” below. internal/ui.PagedTable/ DisplayBuckets (implemented per this addendum, then used for less than a day) are retired, not extended: screen-reader/accessible-mode compatibility – this addendum’s central design constraint – turned out not to be an actual requirement for this tool once discussed directly (DECISIONS.md, “Deprecate termlib; standardize on huh/bubbletea before 0.0.2”), which removes the reason this had to be plain sequential termlib printing instead of a real bordered, chrome-consistent bubbletea component like the rest of the app. Left in place below as the accurate record of what was designed, decided, and briefly shipped, and why it changed – not deleted.

Status (as originally written): designed, not yet implemented. Revises Feature 17 (List Buckets) and the “Show resource lists” menu item’s behavior within the S3 domain menu (21.2). Scoped to S3 for now — Compute and Key Management’s own “Show resource lists” listings (Features 1, 13; ui.DisplayInstances/DisplayImages/ DisplayKeyPairs) keep their current one-shot, unpaginated printing; ask before extending this to them. The pager itself, however, is deliberately built as a generic, reusable internal/ui component, not a bucket-specific one — S3 buckets are its first consumer, so Compute/Key Management can adopt the same mechanism later, when/if their own menus migrate, without a redesign (see “Reusability” below).

Problem. Today, RunS3Menu calls actions.Refresh(ctx) after every successful action (Create Bucket, Configure Website, etc.), and refreshS3 (cmd/clasm/main.go) both re-fetches bucket data AND prints the full bucket table (ui.DisplayBuckets) in the same step. Two problems fall out of that: the full table reprints after every action, not just when the operator asks to see it, cluttering the menu’s redisplay; and DisplayBuckets has no pagination at all (unlike ui.PickList’s existing 50-item paging), so a large bucket count would print unboundedly.

Decision. 1. Split “refresh” into its two separate concerns: re-fetching bucket data (still happens after every action, unchanged, so bucket- selection prompts elsewhere stay current) and displaying the bucket table (now happens only when “Show resource lists” is explicitly chosen from the S3 menu). 2. “Show resource lists” becomes its own paged display, not an inline print: the banner (folding in the page count) and column header repeat on every page, and the operator navigates with three single-key commands — n (next page), p (previous page), q (quit — returns to the S3 menu without printing anything further). n/p are no-ops at the first/last page, matching PickList’s existing boundary behavior. 3. Mockup (approved 2026-07-10):

===== S3 BUCKETS (page 2 of 3, showing 21-40 of 47) =====

NAME                                     REGION     STATIC WEBSITE PURPOSE
caltech-static-assets                    us-west-1  yes            web-hosting
research-data-archive-2024               us-west-2  no             backup
thesis-submissions-fall2025              us-west-2  yes            production
...                                       ...        ...            ...
cl-cold-storage-2023                     us-west-1  no             backup

'n' next page   'p' previous page   'q' quit to S3 menu
Command: 
  1. Reusability. The pager is a new generic function in internal/ui (working name PagedTable), decoupled from any specific resource type: it takes a title-format callback (given page/ totalPages/shown/total, returns the banner line), an already- rendered header line, and already-rendered row strings — it owns only windowing, chrome, and n/p/q input, not column formatting. DisplayBuckets’s existing PadRight/Truncate column rendering is reused as-is to build the header/row strings passed in; only the printing loop changes. This mirrors PickList’s own shape (a generic mechanism domain code plugs labels into), so Compute/Key Management’s DisplayInstances/DisplayImages/DisplayKeyPairs could later call the same PagedTable with their own header/row strings, if and when those menus are revisited — not part of this piece of work, but not precluded by it either.
  2. Stays fully accessible: this is sequential printing throughout — print the banner+header+page of rows, print the command line, read one line of input, then either print the next page’s block or return — no cursor repositioning or redraw at any point, so it behaves identically over a real TTY, a TERM=dumb session, or (per DECISIONS.md, “huh fields are pipe-testable…”) a piped input/output pair in tests. Note this mechanism doesn’t involve huh at all — it’s plain termlib printing and LineEditor.Prompt reading, the same style PickList already uses; “migrating to huh” elsewhere in this codebase and “paging a resource list” are orthogonal, and this design keeps them that way.

Rejected alternatives. - Print the whole table unpaginated, rely on terminal scrollback — today’s actual behavior; rejected per this session’s request (buckets can exceed a screen’s height, and the table shouldn’t reprint after unrelated actions). - Give the paged display a huh.Select/bubbletea-style bounded viewport (like the interactive file manager’s panes) — rejected: that’s a redraw-in-place rendering model, structurally the thing accessible mode has to avoid; would need a parallel accessible-mode fallback for no benefit over plain sequential paging, which already works today via PickList. - Reuse ui.PickList directly instead of a new PagedTablePickList is shaped around choosing ONE item from a single-column label list (it returns a selection). This display shows a multi-column table and makes no selection (q just returns) — close in spirit (same page-size/boundary conventions) but a distinct function.

Consequences. ui.DisplayBuckets is replaced by a PagedTable call site; cmd/clasm/main.go‘s refreshS3 closure splits into a silent data-refresh half (still called after every action) and a separate paged-display call reachable only from the “Show resource lists” menu item; s3MenuItems’ “Show resource lists” entry (s3_menu.go) calls the new paged display instead of a.Refresh(ctx) alone.


S3 Object Management — Interactive File Manager (Design Addendum, 2026-07-09)

Status: implemented 2026-07-09 (internal/filemanager; PLAN.md Phase 20.1) — unit-tested, not yet real-AWS verified (PLAN.md Phase 22). This addendum was design-only when first written; the section below is otherwise left as originally drafted (it’s the accurate design record), except where marked. It supersedes Feature 20 (Sync Local Directory to Bucket) and Feature 21 (Browse/Manage Objects) as S3 menu entry points — both wizards are now retired. One addition beyond this addendum’s original scope: a dedicated Sync action (21.6) was added during implementation so Decision 2 below (“Sync’s directory-mirroring workflow is kept as a first-class, directly reachable capability”) is met literally, not just approximated by manual tag-and-act; see DECISIONS.md, “Add a dedicated Sync action to the file manager.”

Builds on the huh-vs-bubbletea technology evaluation already recorded (continue_next_time.txt; agents/hand-off/ 2026-07-09T220000Z-clasm-rename.spmd): huh was the leading candidate for replacing termlib’s blocking-prompt style generally, evaluated by pulling real source into a scratch module rather than trusting docs. This addendum goes one step further for S3 object management specifically — huh’s blocking forms are sufficient for single-pane browsing and batch selection, but the linked local+bucket workflow below (21.3, 21.5, 21.6) needs a live, simultaneously-visible two-pane view that huh’s sequential fields structurally can’t provide. That one piece is designed as a scoped bubbletea component instead — see 21.8 for why that doesn’t reopen the original “don’t rewrite everything” objection to bubbletea.

21.2. Revised S3 Domain Menu

“Sync Local Directory to Bucket” and the bulk delete-by-prefix case are removed as separate menu entries — both become reachable from inside the interactive file manager (directory-mirroring via double-pane mode, 21.3; bulk delete via tagging filtered matches in either mode, 21.6). Feature 21’s original single-object browse/metadata/delete is folded in the same way rather than kept as a second, parallel implementation: tagging exactly one item and choosing an action in single-pane mode covers that case without a separate wizard.

21.3. Session Start & Linking

Entering “Browse & Manage Objects”: 1. Pick a bucket and region (huh.Select, reusing Feature 17’s already- fetched listing) — this pre-flight step stays on huh; there’s no reason to rebuild bucket selection inside the interactive screen. 2. Prompt (huh.Confirm): link a local directory now? If yes, prompt a path (huh.Input, reusing bucket_sync.go’s existing validateLocalDirectory) and open in double-pane mode; if no, open single-pane (bucket only). 3. Mid-session, the l hotkey links or unlinks a local directory without restarting. When nothing is linked, it prompts for a path via the command line and splits single-pane into double-pane. When a directory is linked, l (or :unlink) goes straight to a direct Confirm (“Unlink <path> and return to single-pane view?”) instead of the command line — added 2026-07-09 after an operator asked for “a way to go from two panels back to displaying only the S3 bucket”; the original design (clear the pre-filled :link <path> field and submit it empty) was technically reachable but not discoverable as the way back. This directly serves “moving between local and bucket as one set of activities” without requiring the operator to plan ahead at launch.

21.4. Screen Layout & Chrome

┌ clasm — S3 File Manager — sql-backups.library.caltech.edu (us-west-2) ─────────────────┐
├───────────────────────────────┬─────────────────────────────────────────────────────────┤
│  LOCAL: /path/on/disk          │  S3: bucket-name/prefix/                                │
│  ...listing...                 │  ...listing...                                          │
├───────────────────────────────┴─────────────────────────────────────────────────────────┤
│ 12 items, 3 tagged (4.3 MB)              filter: db0*                                    │
├───────────────────────────────────────────────────────────────────────────────────────────┤
│ : ____________________________________________________________________________________  │
├───────────────────────────────────────────────────────────────────────────────────────────┤
│ u Upload  d Download  x Delete  f Filter  F Find  S Sync  l Link  Tab Switch  Space Tag  q Quit │
└───────────────────────────────────────────────────────────────────────────────────────────┘

Top to bottom: - Header — mode indicator, bucket name + region, local root once linked. - Pane area — one pane (single-pane mode) or two side by side (double-pane): local on the left, S3 on the right, matching the WinSCP/SFTP-client convention this team is more likely to already know than Midnight Commander’s layout. A pane’s own header row shows an animated spinner + “Loading…” while its listing is being (re)fetched, and Find’s status row shows the same spinner while a search is still running (added 2026-07-09 – both can take a real, noticeable amount of time against a large bucket, and with no feedback the screen just looked frozen/broken). - Status line — per pane: item count, tagged count, aggregate tagged size (needed to see the blast radius of a bulk action before confirming, not just a count), active filter string. - Command line — inert until : or / takes focus; typed verbs (:upload, :delete, :find <pattern>) or filter patterns. - Hotkey legend — single-letter mnemonics, not function keys — F-key mappings are unreliable across terminal emulators and multiplexers, a real enough problem to design around rather than default to. The legend is contextual (e.g. x Delete only shown once something is tagged). The hotkey bar and the colon command line both drive the same underlying action dispatch; neither is a fallback for the other — two paths to the same commands, not a primary and a backup. - Progress/confirm overlay — modal, centered over the pane area. Confirms reuse the existing Confirm/ConfirmDestructive split unchanged (plain yes/no for Upload/Download, type-the-name-back for Delete — Security Consideration #11 still applies without exception). Execution reuses the existing per-item OK/FAIL progress convention as scrolling lines inside the overlay; completion requires an explicit “press any key to continue” rather than an auto-dismiss timer, so a FAIL line can never be hidden by a timeout.

21.5. Pane Navigation & Listing

21.6. Tagging & Actions

21.8. Technology & Architecture Notes

CloudFront Domain

Someday/maybe — not on the active roadmap, no committed timeline (revised 2026-07-09 from “postponed to a later version”; see DECISIONS.md, “Demote CloudFront to someday/maybe…”). No code written. The design below stays valid reference for if this is ever picked back up.

CloudFront’s control plane is a single global API (us-east-1, regardless of where origins live) — this domain’s listing is not region-fanned-out the way Compute/Key Management/S3 are (see “Navigation: Domain Picker” above).

22. List Distributions

Resource listing shown when the CloudFront domain is entered: cloudfront:ListDistributions, showing ID, Domain Name, Origin, and Status (Deployed/InProgress) in one table.

23. Show Distribution Detail

Interactive workflow: pick a distribution, call cloudfront:GetDistribution, display its full origin/behavior/cache config and current status — read-only, no confirmation needed.

24. Create Distribution

Interactive workflow, the CloudFront half of standing up a static website (paired with Feature 19): 1. Pick (or create, handing off to Feature 18) the S3 bucket to serve 2. Create an Origin Access Control for this distribution (cloudfront:CreateOriginAccessControl) if one doesn’t already exist for this bucket 3. Prompt for default root object (default index.html, matching Feature 19’s index document if already configured) 4. Prompt for optional alternate domain name(s) (CNAMEs) — if provided, note plainly that an ACM certificate covering that name must already exist in us-east-1 (certificate provisioning itself is out of scope — see “Deferred to a Later Version”) 5. Confirm before creating (this provisions real, billable infrastructure, though CloudFront’s free tier makes this low-stakes compared to Compute’s destructive operations — a plain confirm, not a type-to-confirm tier) 6. Call cloudfront:CreateDistribution 7. Update the bucket policy to allow only this distribution’s OAC to read it (s3:PutBucketPolicy, scoped by AWS:SourceArn to this distribution) — this is what makes the private-bucket-plus-OAC pattern actually work; without it the distribution returns AccessDenied for every request 8. Poll (unbounded — distribution deployment commonly takes 5–15 minutes) until Deployed, displaying elapsed time, the same pattern as Feature 8’s AMI-creation poll 9. Display the distribution’s domain name

25. Invalidate Cache Paths

Interactive workflow for forcing CloudFront to re-fetch updated content after a Feature 20 sync (CloudFront otherwise serves cached content per each object’s Cache-Control/default TTL): 1. Pick a distribution 2. Prompt for path pattern(s) to invalidate (default /* — everything — with a note that wildcard invalidations are simple but less precise than targeted paths) 3. Confirm (invalidations beyond the first 1,000 paths/month are billable — worth a brief on-screen note, not a blocking warning) 4. Call cloudfront:CreateInvalidation 5. Poll until Completed, displaying elapsed time 6. Confirm completion

26. Project/Environment Tagging Convention (extended)

Feature 12’s tagging convention (Project/Environment tags, defaults suggested at creation, explicit prompt for Environment with no default) extends to the new domains where the underlying AWS resource supports tags: S3 buckets (Feature 18) and CloudFront distributions (Feature 24). Key pairs also support tags but carry comparatively little operational risk on their own, so tagging them is offered but not required the way it effectively is for Compute’s destructive-operation gating. The Environment=production safety-gate behavior itself (the extra warning before type-to-confirm) is not extended to Delete Key Pair or any S3/CloudFront deletion in this round — see “Deferred to a Later Version”.

Architecture

cmd/awsops/
    main.go              ← Entry point; wires config, AWS clients, and the
                            interactive menu loop together

internal/awsclient/       ← Thin, typed wrapper over aws-sdk-go-v2
    ec2.go                 - per-region EC2 client construction (also backs
                              Key Management: DescribeKeyPairs/CreateKeyPair/
                              ImportKeyPair/DeleteKeyPair share this client)
    ssm.go                 - per-region SSM client construction (fstrim,
                              cloud-init AMI extraction, backup archive)
    s3.go                   - S3 client construction; broadened beyond
                              Feature 11's HeadObject-only use to cover the
                              S3 domain (CreateBucket, PutPublicAccessBlock,
                              PutBucketWebsite, PutBucketPolicy, PutObject,
                              ListObjectsV2, DeleteObject)
    cloudfront.go            - CloudFront client construction (single
                              `us-east-1` control-plane endpoint — no
                              per-region fan-out, unlike the other clients)
    iam.go                   - IAM client construction (single client,
                              global service like STS/CloudFront) --
                              ListInstanceProfiles/ListRoles/
                              CreateInstanceProfile/AddRoleToInstanceProfile
                              for Feature 2/3's instance profile pick-or-create
    regions.go              - the configured regions (currently us-west-1, us-west-2)

internal/inventory/       ← Resource listing/aggregation
    instances.go            - ListInstances(ctx) across all regions
    images.go               - ListImages(ctx) (owned AMIs) across all regions
    keypairs.go              - ListKeyPairs(ctx) across all regions
    buckets.go               - ListBuckets(ctx) with per-bucket region +
                              static-website-hosting status
    distributions.go         - ListDistributions(ctx) (global, not
                              region-fanned-out)

internal/ui/               ← Terminal interaction (replaces show_pick_list,
    picklist.go               display_instances/display_amis, prompts) --
    display.go                stays generic/parameterized; PickList[T]
    prompt.go                 needed no changes for the domain picker below

internal/workflow/         ← One file per operation (replaces the Bash
    launch_instance.go        "_workflow" functions; also backs Feature 3
                              (Create from Cloud-Init YAML) via the same
                              launch/poll/cloud-init-check execution path
    domain_menu.go           - the top-level domain picker (RunDomainPicker,
                              DomainActions) + the "Back to domain picker"
                              vs. "genuine exit signal" distinction every
                              domain's own menu loop reports through --
                              lives here, not internal/ui, so it can share
                              menu.go's dispatch-error sentinels
    create_instance_profile.go - IAM instance profile pick-or-create
                              (Feature 2/3): promptIAMInstanceProfileOrCreate,
                              createInstanceProfileFromRole
    power_state.go           - Start/Stop/Terminate EC2 Instance
    manage_tags.go           - Manage Tags: add/update/remove, instance or AMI
    create_ami.go
    remove_ami.go
    cloud_init.go            - Show/Export Cloud-Init (instance + AMI paths)
    backup_archive.go        - Backup Archive & Trim (upload, verify, delete, fstrim)
    keypair_create.go         - Create/Import/Delete Key Pair (Features 14-16)
    keypair_import.go
    keypair_delete.go
    bucket_create.go          - Create Bucket, Configure Static Website
    bucket_website.go           Hosting (Features 18-19)
    bucket_sync.go             - Sync Local Directory to Bucket (Feature 20)
    bucket_browse.go           - Browse/Manage Objects (Feature 21)
    distribution_create.go     - Create Distribution, Invalidate Cache Paths
    distribution_invalidate.go   (Features 24-25)
    menu.go                   - reworked to drive the domain picker and
                              delegate to each domain's menu loop

Each internal/workflow file depends on internal/awsclient and internal/inventory through small interfaces (e.g. an EC2API interface covering just the SDK methods actually used, and an S3API interface covering just HeadObject for Feature 11’s independent verification), so tests can supply fakes without hitting real AWS or shelling out to a mock CLI binary.

Data Flow

User Interaction
     │
     ▼
Menu Selection (1-8)
     │
     ▼
┌─────────────────────────────────────┐
│  For each operation:                │
│  1. Fetch current resource data     │ ← ec2.DescribeInstances/DescribeImages
│                                        (typed SDK calls, one per region)
│  2. Filter/sort for display         │ ← Owned AMIs only, aggregate regions
│  3. Present pick list to user       │ ← Numbered menu with formatting
│  4. Collect additional parameters   │ ← Interactive prompts with validation
│  5. Perform AWS API call            │ ← ec2.RunInstances/CreateImage/
│                                        DeregisterImage (typed SDK calls)
│  6. Display results                 │ ← Success/failure with details
│  7. Refresh displays                │ ← Return to main menu with updated data
└─────────────────────────────────────┘

File Structure

awstools/
├── DESIGN.md              ← This document
├── DECISIONS.md           ← Architecture and UX decisions (Bash + Go history)
├── PLAN.md                ← Implementation plan (Go)
├── TEST_PLAN_REAL_AWS.txt ← Manual verification steps against real AWS
├── go.mod / go.sum
├── cmd/awsops/            ← Go entry point (see Architecture above)
├── internal/              ← Go packages (see Architecture above)
├── ec2_ami_manager.bash   ← Reference only; retire once Go reaches parity
│                             and passes TEST_PLAN_REAL_AWS.txt
├── ami_copy.bash          ← Reference only; superseded by ported
│                             capabilities (see DECISIONS.md, 2026-06-30)
└── tests/                 ← Bash/BATS tests for ec2_ami_manager.bash
                              (kept until the Bash tool is retired; Go tests
                              live alongside their packages as *_test.go)

check_ami.bash and check_ec2_instances.bash were already retired — see DECISIONS.md (2026-06-30 “Retire check_ami.bash and check_ec2_instances.bash”).

Dependencies

No jq, no AWS CLI, and no bash/grep/tr version- or locale-dependent behavior at runtime — the compiled binary only needs the Go standard library, the AWS SDK, and termlib (both pre-approved dependencies per CLAUDE.md).

Assumptions

  1. AWS credentials are already configured and resolvable by the SDK’s default credential chain (~/.aws/credentials, environment variables, or SSO)
  2. The tool’s own identity (the operator running it) needs: ec2:DescribeInstances, ec2:DescribeImages, ec2:DescribeKeyPairs, ec2:DescribeSecurityGroups, ec2:DescribeSubnets, ec2:DescribeVpcs, ec2:DescribeIamInstanceProfileAssociations, ec2:RunInstances, ec2:StartInstances, ec2:StopInstances, ec2:TerminateInstances (also used for cloud-init AMI extraction cleanup), ec2:CreateImage, ec2:DeregisterImage, ec2:CreateTags, ec2:DeleteTags, ec2:DescribeTags (for the Project/Environment tagging convention and Manage Tags), ec2:DescribeInstanceAttribute (for Show/Export Cloud-Init), ec2:DescribeVolumes (for Create AMI from Instance’s volume-size time estimate and prior-snapshot detection – missing from this list until Phase 10 surfaced it), ec2:DescribeInstanceTypeOfferings (Feature 2/3’s instance-type-vs- subnet-Availability-Zone pre-flight check), ec2:DescribeInstanceTypes (Feature 2/3’s instance-type-vs-AMI-ENA- support pre-flight check), ssm:SendCommand, ssm:GetCommandInvocation, ssm:DescribeInstanceInformation (fstrim, Show/Export Cloud-Init’s AMI path, and Backup Archive & Trim), s3:HeadObject (for Backup Archive & Trim’s independent verification step — a read-only check against whatever bucket the operator specifies), iam:ListInstanceProfiles, iam:ListRoles, iam:CreateInstanceProfile, iam:AddRoleToInstanceProfile (Feature 2/3’s IAM instance profile pick-or-create; see DECISIONS.md, “Support picking or creating an IAM instance profile from within awsops”). For Key Management (Features 13-16): ec2:ImportKeyPair, ec2:DeleteKeyPair (ec2:DescribeKeyPairs and ec2:CreateKeyPair are already listed above). For the S3 domain (Features 17-21): s3:ListAllMyBuckets, s3:GetBucketLocation, s3:GetBucketWebsite, s3:CreateBucket, s3:PutPublicAccessBlock, s3:PutBucketWebsite, s3:PutBucketPolicy, s3:PutObject, s3:ListBucket, s3:GetObject, s3:DeleteObject. For the CloudFront domain (Features 22-25): cloudfront:ListDistributions, cloudfront:GetDistribution, cloudfront:CreateDistribution, cloudfront:CreateOriginAccessControl, cloudfront:CreateInvalidation, cloudfront:GetInvalidation.
  3. Separately, each target instance’s own IAM instance profile needs s3:PutObject (and likely s3:ListBucket scoped to its own prefix) on the backup destination bucket, for Backup Archive & Trim’s upload phase to work — this is a different AWS principal from #2 above, provisioned via the instance’s own profile/cloud-init, not by this tool
  4. The S3 bucket for backup archival does not exist yet as of this writing — real-AWS verification of Backup Archive & Trim is blocked on it being created (tracked outside this project)
  5. Default VPC and subnet exist in each region, or user will provide specific values
  6. Key pairs exist in each region, or user will create them separately

Error Handling Strategy

  1. AWS API errors: the SDK returns typed errors; unwrap and display the AWS error code/message clearly (no more parsing free-text CLI stderr)
  2. Validation errors: prompt user to re-enter invalid inputs
  3. Network/timeouts: retry with exponential backoff (max 3 attempts), using the SDK’s built-in retry support where possible
  4. Missing dependencies: clear error message if AWS credentials cannot be resolved at startup
  5. Permission errors: display the required IAM permission and exit

Debug Logging

-debug writes a line-delimited JSON (JSONL) record of every AWS SDK call awsops makes during the session, to a timestamped file in the current directory (awsops-debug-<timestamp>.jsonl), for diagnosing unexpected behavior without re-running under a debugger. Modeled on the same pattern used for ~/Laboratory/harvey’s own --debug JSONL log.

Security Considerations

  1. Never store AWS credentials in the binary or repo; rely on the SDK’s standard credential chain
  2. Always confirm destructive operations (AMI removal)
  3. Display instance costs/estimates when creating (if possible)
  4. Warn about public AMIs vs private AMIs
  5. For AMI creation from instances: warn about any sensitive data on the instance, and carry forward the Invenio RDM crash-consistency guidance for running-instance snapshots
  6. Show/Export Cloud-Init’s AMI path launches a real, billable instance — it must warn the user this costs time/money before proceeding (unlike every other read-only operation in this tool), and it must guarantee the temporary instance is terminated even when SSM never comes online or the extraction command fails, so a failed extraction never leaves a forgotten running instance behind
  7. Backup Archive & Trim deletes real backup files — it must never delete a file based solely on the instance’s own self-reported upload success; the tool’s independent s3:HeadObject verification is the actual authorization for the delete step, not a redundant nice-to-have
  8. -debug’s JSONL log (see “Debug Logging” above) is written unencrypted to the current directory and is not automatically cleaned up — it’s the operator’s responsibility to remove old debug logs, same as any other local diagnostic file
  9. A newly created key pair’s private key material never touches AWS again after ec2:CreateKeyPair returns it — awsops writes it to ~/.ssh/<name>.pem with 0600 permissions immediately and never logs the raw material anywhere (including -debug’s log; see “Debug Logging” above)
  10. S3 buckets default to s3:PutPublicAccessBlock fully enabled at creation (Feature 18); a public-read bucket policy is never the default path to a static website — CloudFront + Origin Access Control is (Feature 19, Feature 24), so a bucket stays private and only a specific CloudFront distribution can read it (s3:PutBucketPolicy scoped by AWS:SourceArn, Feature 24 step 7). An operator can still opt into a public-read bucket policy explicitly, but that path requires its own separate confirmation that plainly states the bucket becomes world-readable directly
  11. Feature 20’s bucket-only-object deletion (during a sync) and Feature 16’s Delete Key Pair both require a separate explicit confirmation step from whatever triggered them — never folded into a broader “yes” (e.g. “yes, sync” must never also silently mean “yes, delete”), the same principle already applied to Feature 11’s upload/verify/delete separation
  12. Feature 24 (Create Distribution) provisions real, billable infrastructure and must say so before creating; it is not gated at Compute’s destructive-operation tier (dry-run + type-to-confirm) since creating a distribution isn’t itself destructive, but the on-screen confirmation should be explicit that this isn’t a free, instantaneous operation
  13. Feature 21.1 (Manage Bucket Lifecycle Policies) doesn’t delete anything itself — it edits rules AWS evaluates later (typically within 24-48 hours per AWS’s own lifecycle evaluation cadence, not instantly). Adding or editing an expiration rule is still a plain yes/no confirm (not the stronger dry-run + type-to-confirm tier), but the on-screen confirmation must say plainly that this schedules future automated deletion, not an immediate one — an operator should never be surprised days later by objects that quietly vanished

Domain Knowledge Carried Forward from the Bash Version

These are operational facts specific to this team’s infrastructure (primarily Invenio RDM instances) that must not be lost in the rewrite — ported from ami_copy_basic_steps.md and DECISIONS.md:

Deferred to a Later Version

These directly serve the stated product goal (speed up upgrades, create accurate test environments with confidence) but are intentionally out of v1’s scope — see DECISIONS.md, “V1 scope: ship the four primitives first, defer composite workflows” and “Structure workflows for future record/replay”. Recorded here so they aren’t lost: