AWS Tools — awsops — Design
2026-07-01: Retargeted to Go. See
DECISIONS.md(“Retarget implementation from Bash to Go”) for why.ec2_ami_manager.bashremains 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. SeeDECISIONS.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, andtests/*.batshave been deleted from this repo;awsopsis now the sole implementation and the working reference for Compute domain behavior. SeeDECISIONS.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:
- A declarative infrastructure-as-code tool. It
doesn’t define desired state, diff against reality, or reconcile drift.
Terraform, Pulumi, and AWS CDK already solve that problem well; if this
team ever wants version-controlled, reproducible environment
definitions, one of those is the right tool, not a
awsopsfeature to grow toward. - An AMI-baking pipeline. Packer (and AWS EC2 Image Builder) already automate “base AMI + cloud-init/provisioning script → new AMI.” The deferred “Bake AMI from cloud-init” idea (below) is v1’s primitives composed by hand, not a competing pipeline tool.
- A general-purpose AWS CLI replacement. It wraps a curated, opinionated subset of operations this team actually performs, not the full breadth of any single AWS service’s API.
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”.
- Entirely optional. If the file doesn’t exist at the
resolved path (default or
-config-specified), built-in defaults apply and the tool behaves exactly as it always has — no config file is required to runawsops. - Fails loudly on a real mistake. If the file exists
but is malformed YAML,
awsopsexits with a clear parse error rather than silently falling back to defaults — a botched config that’s silently ignored could mask a typo (e.g. a misspelled region) behind confusing “why isn’t my region showing up” behavior. - Per-field defaults, not all-or-nothing. If the file exists and parses but a given setting is absent or empty, that setting’s own built-in default applies. A config file only needs to mention what it actually wants to override; it never needs to restate everything.
- A single flat struct, not a versioned schema.
internal/config.Confighas one YAML-tagged field per setting. Adding a new setting later means adding a field, a default constant, and wiring it into whatever consumes it — no migration machinery, which would be over-engineering for a single-operator-maintained local dotfile (not a multi-tenant service config).
regions
regions:
- us-west-1
- us-west-2Defaults 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/backupsAn 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.
Navigation: Domain Picker
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.Model —
topBorder, 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.
ui.Prompt(free-text input, optional default + validator; ~30 call sites) → rebuilt onhuh.NewInput(), following the same split-into-testable-core pattern already used for every Menu/Picker conversion (Phase 20.2 etc.): a thin public wrapper plus aninput io.Reader, output io.Writer-accepting core that tests drive via huh’s accessible-mode pipe path.WithDefault/WithValidatormap tohuh.Input.Value(&s)with a default pre-fill and.Validate(func(string) error)— huh already re-prompts on a validator error without any surrounding loop needed.Confirm(y/n, re-prompt on unrecognized input) →huh.NewConfirm(). The re-prompt-on-bad-input loop disappears entirely: a toggle can’t produce unrecognized input.ConfirmDestructive(type-to-confirm, single attempt, mismatch cancels rather than re-prompting) →huh.NewInput()with no validator (a validator would make huh re-prompt until correct, changing the single-attempt semantics); the exact-match check runs after the field returns, same as today. The instructional text currently printed viat.Printfbefore the prompt becomes the field’s.Description().- Plain status/error output
(
t.Println/t.Printffor things like “Exiting.”, “Error: %s”, the progress ticker’s periodic elapsed-time line,loadUserData’s “looks like an existing file” note) → the*termlib.Terminalparameter becomes a plainio.Writer;t.Println/t.Printfbecomefmt.Fprintln/fmt.Fprintf;t.Refresh()calls are deleted outright (nothing buffers anymore, so there’s nothing to flush). In the ~9 files wheret/lewere pure pass-through (never called directly, only forwarded to a callee), the parameter is dropped or renamed tow io.Writerdepending on whether that file’s own callees still need one. progress_ticker.go— mechanical parity only:*termlib.Terminal→io.Writer,termlib.FormatDuration→ a local reimplementation (samem:ss/h:mm:ssrounding). No new bubbletea spinner component in this pass — explicitly deferred to a later chrome-improvement pass (see TODO.md) rather than mixed into a pure removal.termlib.Bold/Reset/Green/Red/Yellow,PadRight/Truncate→ reimplemented locally ininternal/uias the same small set of ANSI constants and rune-aware pad/truncate helpers actually used (~20 lines total). No new dependency (e.g.lipgloss) introduced in this pass — deferred to the later chrome-standardization pass so this removal stays scoped to “delete termlib,” not “restyle everything.”termlib.ErrInterrupted→ once every input path runs through huh, Ctrl+C during input surfaces ashuh.ErrUserAborted, which the Menu-tier conversions already map (mapMenuPickerErr,huhCancelledIsNil). Each remainingerrors.Is(err, termlib.ErrInterrupted)check is replaced with the equivalenthuh.ErrUserAbortedcheck at its call site, not a blanket rename — some of these sites may find the check is already redundant once the underlying prompt is gone.cmd/clasm/main.go—termlib.New(out)/termlib.NewLineEditor(...)construction is deleted;os.Stdoutis passed directly wherever anio.Writeris still needed.
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:
tui.Theme() *huh.Theme— built fromhuh.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 whatThemeCharmalready does for those same elements) — deliberately omittingThemeCharm’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 ofThemeCharm, not a new invention.internal/tui/box.go’s border/title rendering (TopBorder,BottomBorder,Divider,SplitDivider,MergeDivider) styled with the same indigo + bold vialipgloss.NewStyle(). BecauseListViewModel,PickerModel, andinternal/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:
- Create EC2 Instance from Launch Template – a third
way to create an instance, alongside Feature 2 (from AMI) and Feature 3
(from cloud-init YAML). Pick a template, then a version: the prompt
pre-fills
$Default(matching Backup Archive & Trim’s own recalled-but-overridable default convention, DECISIONS.md “Recall Backup Archive & Trim’s instance/directory choices per-instance”) but stays editable to an explicit version number or$Latestbefore the launch executes. Collects nothing else – the template supplies every other launch parameter; this is deliberately not a hybrid “template plus override individual fields” wizard, since that was the earlier design-conversation confusion (A3) the operator explicitly resolved: it’s just another way to create an instance, parallel in shape to Features 2 and 3, not a variant of either. - List Launch Templates – folds into the existing “Show resource lists” List-tier display as a new resource type, alongside instances/AMIs, not a separate top-level action.
- Show Launch Template – pick a template, pick a
version (default pre-selected), display the curated detail fields above.
Modeled on the existing AMI summary display (the same modest field set
Image/AMI-related screens already show: ID, name, creation date/version, region, tags) plusMetadataOptions, shown explicitly because it’s the field the IMDSv2 work below cares about. IfHttpTokensisn’trequired, the display flags it right there (passive – no separate audit action; the operator asked for “recommended for existing templates if missing,” not a dedicated scan). - Create Launch Template from Cloud-Init YAML –
collects a YAML file path (identical prompt to Feature 3, including
reading from a file only, never inline text) plus the same
AMI/instance-type/ subnet/security-group/IAM-profile/tag prompts Feature
3 already asks, then
CreateLaunchTemplate(which implicitly creates version 1).MetadataOptionsis set to required unconditionally – not a prompt, since this is a security default, not an operator choice (mirrors IMDSv2 enforcement below). - Sync Cloud-Init YAML to a Template – pick an
existing template, pick a YAML file, and:
- Fetch the version to compare against (the version the operator picks
– normally
$Default), decode itsUserData. - Compare the decoded text against the local YAML file’s content. If identical, report “no changes – nothing to sync” and stop; no version is created. This is the no-op detection the operator asked for (A10) – Tom’s own framing of “does this actually require a new version” before bumping one.
- If different, render a plain-text unified diff (A8: start simple)
via
github.com/aymanbagabas/go-udiff’sUnified(oldLabel, newLabel, old, new string) string– already present ingo.sumas an indirect dependency (pulled in transitively viacharmbracelet/x/exp/teatest, used byinternal/filemanager’s own tests), so this promotes it from indirect to direct rather than introducing a new one – the same move Phase 20.24 already made forx/ansi. - Show the diff, require explicit confirmation, then
CreateLaunchTemplateVersionwith the newUserData. - Never auto-promotes the new version to default (A9) – that’s always the separate action below. The operator specifically expects to experiment with in-progress template versions during development without accidentally changing what a plain “Create from Launch Template” launch picks up by default.
- Fetch the version to compare against (the version the operator picks
– normally
- Promote Launch Template Version to Default –
ModifyLaunchTemplatewithDefaultVersionset to the chosen version number. Its own explicit action, never a side effect of Sync. - Delete Launch Template Version(s) – prune specific
stale versions (
DeleteLaunchTemplateVersions) without touching the whole template – “so no one accidentally chooses them” (A5), e.g. an abandoned experimental version from mid-development. - Delete Launch Template – removes the whole template
(
DeleteLaunchTemplate), for when the software system it was built for is retired entirely. Both deletion actions go through the same safety-first shape Feature 9 (Remove AMI) already established: show what would be deleted, an explicit extra warning if the template (or the version, for a single-version prune) is taggedEnvironment=production, then type-to-confirm.
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”):
- Plain
RunInstances(Features 2 and 3,launch_execute.go) – currently sets noMetadataOptionsat all (confirmed by readinglaunch_execute.go:51-60– the original TODO.md bug). GainsMetadataOptions: &types.InstanceMetadataOptionsRequest{HttpTokens: types.HttpTokensStateRequired}. - Every new launch template
(
CreateLaunchTemplate) – itsRequestLaunchTemplateData.MetadataOptionsgetstypes.LaunchTemplateInstanceMetadataOptionsRequest{HttpTokens: types.LaunchTemplateHttpTokensStateRequired}unconditionally. - Existing templates missing it – flagged passively
in Show Launch Template / List Launch Templates (above), not auto-fixed
– changing an existing template’s
MetadataOptionsis 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:
- AWS rejects a
"v"-prefixed version number outright; this project’s own display convention (launchTemplateLabel’s “default v2”) made typing one a near-certainty. Fixed at the input boundary (normalizeVersionSelector), not by changing the display format. - Show Launch Template gained “list all versions” and “diff two versions” alongside its original single-version detail view – “there’s another version” alone wasn’t enough to know what changed.
- Sync’s confirmation diff (and the new version-diff) render through the shared List-tier component instead of a raw, potentially- off-screen text dump.
- Compute’s “Show resource lists” split into three menu entries (Show instances/AMIs/launch templates) – paging through all three to reach the one wanted felt awkward. S3/Key Management, each with a single resource type, were left alone.
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:
pickInstance(existing Picker-tier helper).- Resolve the instance’s root EBS volume:
DescribeInstancesalready returnsBlockDeviceMappings; match the entry whoseDeviceNameequals the instance’sRootDeviceNamefor itsEbs.VolumeId, thenDescribeVolumesfor its current size (reusesGatherVolumeInfo’s shape, scoped to that one volume). - Prompt the new size in GB, validated
>=current size (ModifyVolumecannot 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). - Confirm, with the same
Environment=productionextra-warning gate Feature 9 (Remove AMI) established, since this touches a live, possibly-production instance. ec2:ModifyVolume(newEC2APImethod), then pollec2:DescribeVolumesModifications(newEC2APImethod) untilModificationStateisoptimizingorcompleted– AWS’s own guidance is that the volume is safely usable once it leavesmodifying, not only once fullycompleted.- Then automate the OS-side growth via SSM, reusing
the existing
SSMAPI.SendCommand/GetCommandInvocationmechanism (cloud_init_check.goalready polls a command this way for cloud-init status): run anAWS-RunShellScriptdocument that:- Resolves the root filesystem’s underlying block device and partition
number via
findmnt -no SOURCE /(not a hardcoded<disk>1assumption – 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 runsresize2fs(ext4) orxfs_growfs(xfs) accordingly. - If SSM isn’t online for the instance (same “skip, don’t fail”
precedent as
checkCloudInitCompletion’sresult.Skipped), the workflow reports the volume-level resize as done and prints the same manualgrowpart/resize2fsinstructions the operator used as their real-world workaround, rather than failing the whole action. - If
findmnt/lsblkreports 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.
- Resolves the root filesystem’s underlying block device and partition
number via
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:
- The
"(none)"choice is removed. An instance profile is now mandatory, same as IMDSv2’srequiredhas nooptionalescape hatch. - 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 everywherepromptIAMInstanceProfileOrCreateis 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:
- No existing association:
ec2:AssociateIamInstanceProfile. - An existing association:
ec2:ReplaceIamInstanceProfileAssociation(AWS allows only one active association per instance – callingAssociateIamInstanceProfileagain would error).
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:
inventory.Imagegains anArchitecture stringfield, populated the same wayEnaSupportalready is:imageFromSDK(internal/inventory/images.go, owned AMIs) andlistOfficialUbuntuAMIsInRegion(official_ubuntu_amis.go, curated Ubuntu AMIs) both set it directly from the SDK’s ownArchitecturefield (types.ArchitectureValues, a string type – noaws.To*needed).curatedUbuntuReleasesgains 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-*andubuntu-{noble,jammy}-{24.04,22.04}-arm64-server-*, confirmed patterns as above.instanceTypeChoice(launch_prompts.go) gains anarchfield. 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.promptInstanceTypegains anarch stringparameter:""means “no filter, show everything” (preserves current behavior exactly); otherwise the choice list is filtered to entries whosearchmatches (plus the “Other” free-text escape hatch, always shown regardless). The two top-level collection functions (collectLaunchInstanceParams/collectLaunchInstanceParamsFromCloudInit) passimage.Architectureat their initial call, so the instance-type list only ever offers types compatible with the already-picked AMI. The two remediation call sites insideensureInstanceTypeENACompatible/ 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: ""keydefaults to"Origin"if unset – the tag key clasm treats as the categorization convention. Configurable (not hardcoded) since this team’s actual tagging vocabulary, including the key name itself, isn’t settled yet.dld_valuedefaults to""(empty) if unset, meaning no value is recognized as DLD-owned yet – every resource reads as “not recognized as DLD’s” for the read-only guard until this is set, and every browse-list row showsOrigin’s literal value (or “(unset)”) regardless. Once the user’s group settles on a real value (e.g."DLD") and sets it here, resources already taggedOrigin=<that value>become recognized without any further clasm changes.- Per this project’s config-loading convention
(
internal/config): a missing or malformedorigin_tagsection falls back to these defaults per-field, not all-or-nothing; a malformed config file overall still fails loudly, same as every other setting.
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:
- Switch
encodeUserDatatogzip.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. - A hard pre-flight size check inside
encodeUserDataitself, after gzip-compressing and before base64-encoding. NewmaxUserDataBytes = 16384constant (matching AWS’s own documented limit, no longer just a source comment); if the compressed byte count exceeds it,encodeUserDatareturns an error stating the compressed size and how far over the limit it is, instead of proceeding to a doomed AWS call. This requires wideningencodeUserData’s signature to(string, error)– matchingdecodeUserData’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.
Cloud-Init Picker: Discoverable Cancel + a Second, Related Bug (Design Addendum, 2026-07-28, PLAN.md Phase 20.45)
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.
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”) returnsui.ErrCancelledinstead of attempting to read a file named “q”. Accepted trade-off: a real cloud-init file literally namedq(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@pathor a bare path already, perloadUserData’s own docs).SyncLaunchTemplatewraps its call the same way its siblings already do:return cancelledIsNil(w, syncLaunchTemplate(ctx, w, clients, lt, nil, nil)). This also fixes cancelling at thepromptLaunchTemplateVersionstep (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.
Pick a bucket (from the already-fetched bucket listing, Feature 17)
s3:GetBucketLifecycleConfiguration(aNoSuchLifecycleConfigurationerror means “no rules yet,” not a failure) and display existing rules (ID, prefix scope or “whole bucket”, transition(s), expiration)Branch on the bucket’s
Purposetag (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
Whichever path was used, write the complete modified rule set via
s3:PutBucketLifecycleConfigurationand 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:
- Reusability. The pager is a new generic function in
internal/ui(working namePagedTable), 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, andn/p/qinput, not column formatting.DisplayBuckets’s existingPadRight/Truncatecolumn rendering is reused as-is to build the header/row strings passed in; only the printing loop changes. This mirrorsPickList’s own shape (a generic mechanism domain code plugs labels into), so Compute/Key Management’sDisplayInstances/DisplayImages/DisplayKeyPairscould later call the samePagedTablewith 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. - 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=dumbsession, or (per DECISIONS.md, “huh fields are pipe-testable…”) a piped input/output pair in tests. Note this mechanism doesn’t involvehuhat all — it’s plaintermlibprinting andLineEditor.Promptreading, the same stylePickListalready 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 PagedTable — PickList 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
- Show resource lists
- Create Bucket (Feature 18, unchanged)
- Configure Static Website Hosting (Feature 19, unchanged)
- Browse & Manage Objects — opens the interactive file manager (21.3-21.8) described below, single-pane by default
- Manage Bucket Lifecycle Policies (Feature 21.1, unchanged)
- Delete Bucket (unchanged)
- Back to domain picker
“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
- Panes navigate independently — each side browses to any folder/prefix on its own; there is no requirement that both point at corresponding paths. This matches the tag-in-focused-pane, act-on-other-pane convention directly (21.6) and stays more flexible (e.g. uploading from one local folder into an unrelated bucket prefix) than an always-synced, always-diffing view would allow.
- Rows sort folders-then-files, alphabetical within each group.
- S3 listing uses
s3:ListObjectsV2withDelimiter=/, one directory level per call (CommonPrefixesfor folders,Contentsfor files) rather than a full-prefix listing grouped client-side. Per-level browsing stays cheap regardless of how deep or large the tree is below the current level. f(or/on the command line) filters the focused pane’s current level by substring match against already-fetched rows — cheap, since delimiter-based listing means “current level” is never the whole bucket, and instant/synchronous (no spinner needed the way Find’s recursive scan needs one, 21.4). A filter starting with/is matched via the same anchored form Find uses (21.7) instead: an exact/glob match of the current level’s basenames rather than a substring, e.g./index.htmlmatches only a file named exactly that, notmyindex.html5too – added (2026-07-09) so the/-prefix convention means the same thing whether typed as a filter or a Find pattern.- The local pane lists one directory level at a time
(
os.ReadDir), not the full recursive walkbucket_sync.gouses for diffing — that recursive traversal is reused specifically by Find (21.7) and by the double-pane linked workflow’s own diff step, not by ordinary browsing.
21.6. Tagging & Actions
Spacetags/untags the row under the cursor;*tags every row currently visible (post-filter) in the focused pane. Pattern-based tag/untag (mc’s+/-) is deliberately left out of this design —*after filtering already covers “tag everything matching X.”- Action keys operate on the focused pane’s tagged set (or the row
under the cursor if nothing is tagged):
uUpload (Create/Update) — only available when a local directory is linked; tagged local files ares3:PutObject’d into the bucket pane’s current folder (mc/WinSCP convention: source is the focused/tagged pane, destination is the other pane’s current position).dDownload (Read) — tagged bucket objects (s3:GetObject) land in the local pane’s current directory.xDelete — tagged bucket objects removed (s3:DeleteObject). This is how both Feature 21’s old single-object delete and the old bulk delete-by-prefix case are covered by one path now.mShow metadata — carried forward from Feature 21’s original per-object metadata display (s3:HeadObject); applies to the row under the cursor.SSync (added during implementation, not in this addendum’s original scope — see DECISIONS.md, “Add a dedicated Sync action to the file manager”) — only available when a local directory is linked. Diffs the entire linked directory against the entire bucket by key+size (internal/s3diff.Compute, the same logic Feature 20’s retired wizard used, not reimplemented) — not scoped to either pane’s current navigated position, matching the original wizard’s whole-tree semantics. Upload candidates confirm first (plainConfirm); only once that stage is accepted or skipped (no upload candidates) does the delete stage appear (ConfirmDestructive) — the two are never bundled into one prompt (Security Consideration #11), and declining the upload stage aborts before the delete stage is ever shown.
- Confirmation: plain
Confirmbefore Upload/Download,ConfirmDestructive(type the bucket name, or the active prefix) before Delete — unchanged from Feature 20/21’s existing security posture, just reachable from one screen instead of three separate workflows. - Open implementation-time question, not resolved by this design pass:
whether to batch deletes via
s3:DeleteObjects(up to 1000 keys per call) instead of porting the current one-DeleteObject-call-per-key loop — logged previously as a “nice to have” (continue_next_time.txt) and worth folding in since the delete path is being rebuilt anyway.
21.7. Find (recursive pattern search)
- Hotkey
For command-line:find <pattern>— distinct fromf’s current-level-only filter. - Pattern is a shell glob matched against each entry’s
basename, evaluated recursively at every depth below the
focused pane’s current position (Go stdlib
path/filepath.Matchsemantics, including backslash-escaping) — the same behavior asfind <dir> -name '<pattern>'.*.goand\.gitboth work as plain globs; neither needs a regex engine. Recursion starts at the focused pane’s current directory, not always the tree root, matching Unixfind’s own convention and avoiding an unbounded scan when the operator only meant to search what they’re currently looking at. - A pattern starting with
/is anchored to the search’s starting point instead of matched against the basename alone: the leading/is stripped and the remainder is matched (still viafilepath.Match, so*still won’t cross a/) against each entry’s full path relative to that starting point./index.htmltherefore matches only a root-levelindex.html, notsub/index.html— added (2026-07-09) because basename-only matching couldn’t express “just the one at the root” when the same filename legitimately exists at other depths too (a common case: every static site under a bucket has its ownindex.html). - Matches both files and directories/pseudo-folders.
- Results replace the focused pane’s listing with a
flat list, each row showing the path relative to the search’s starting
point (not just the basename, since matches can span multiple
subdirectories). Normal tagging (
Space,*) works directly on results, so matches can be acted on immediately without a detour back through normal browsing. Enteron a result jumps back to normal hierarchical browsing at that match’s parent directory, cursor on it;Escdiscards the find view and returns to normal browsing at the prior location.- On the S3 side this means an on-demand, full recursive
ListObjectsV2(noDelimiter) under the current prefix — the same listing cost Feature 20 (Sync) and the old delete-by-prefix case already paid when invoked, just user-triggered from inside the browser now rather than automatic. Shows a live “Searching… (N scanned, M matched)” status; cancellable (Esc/Ctrl-C) since a deep prefix on a large bucket could take a while. - The local side reuses
bucket_sync.go’s existingfilepath.WalkDir-basedwalkLocalTreetraversal, with the glob match layered on top — no new local-filesystem traversal code needed.
21.8. Technology & Architecture Notes
bubbletea, scoped to this one screen — not the full-application rewrite the original evaluation ruled out. That evaluation’s objection was the cost of rewriting all ~40internal/workflowwizards’ blocking, linear control flow into Elm-architecture state machines; it doesn’t apply to building one bounded, genuinely interactive component while every other wizard (Create Bucket, Configure Website, Lifecycle Policies, Delete Bucket, and this screen’s own bucket-selection pre-flight, 21.3) stays on huh’s blocking synchronous fields.- No new dependency weight beyond adopting huh at all: huh already
pulls in
bubbletea,bubbles, andlipglosstransitively, so a scopedbubbletea.Programfor this screen doesn’t add anything huh wasn’t already going to bring in. - This is the one place in the S3 domain design where a live, stateful, two-pane view is unavoidable — every other S3 feature (17-19, 21.1) stays exactly as designed, huh fields only.
- Prototype this screen’s single-pane mode first (the simpler of the two) before committing to double-pane/link/Find — same “prototype cheaply before porting everything” guidance already recorded from the original huh evaluation, now applied one level deeper.
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
- Go: 1.26+ (matches
codemeta.json’ssoftwareRequirements, module-based build) - github.com/aws/aws-sdk-go-v2 and its
ec2,ssm,s3,sts,iam, andcloudfrontservice packages — seeDECISIONS.md(“Use official AWS SDK for Go v2”;iamadded for Feature 2/3’s instance profile pick-or-create,cloudfrontfor the CloudFront domain) - github.com/rsdoiel/termlib for terminal output and
interactive input (
internal/ui) — seeDECISIONS.md(“Use github.com/rsdoiel/ termlib for the Terminal UI”) - gopkg.in/yaml.v3 for
~/.awsopsconfig file parsing (internal/config) — seeDECISIONS.md, “Add a~/.awsopsYAML config file for awsops’ own operational settings” - Go standard library
testingfor unit tests; no external test framework needed (replaces BATS)
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
- AWS credentials are already configured and resolvable by the SDK’s
default credential chain (
~/.aws/credentials, environment variables, or SSO) - 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; seeDECISIONS.md, “Support picking or creating an IAM instance profile from within awsops”). For Key Management (Features 13-16):ec2:ImportKeyPair,ec2:DeleteKeyPair(ec2:DescribeKeyPairsandec2:CreateKeyPairare 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. - Separately, each target instance’s own IAM instance
profile needs
s3:PutObject(and likelys3:ListBucketscoped 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 - 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)
- Default VPC and subnet exist in each region, or user will provide specific values
- Key pairs exist in each region, or user will create them separately
Error Handling Strategy
- AWS API errors: the SDK returns typed errors; unwrap and display the AWS error code/message clearly (no more parsing free-text CLI stderr)
- Validation errors: prompt user to re-enter invalid inputs
- Network/timeouts: retry with exponential backoff (max 3 attempts), using the SDK’s built-in retry support where possible
- Missing dependencies: clear error message if AWS credentials cannot be resolved at startup
- 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.
- Every EC2/SSM/S3/STS call is wrapped by a logging decorator
(
internal/awsclient’sWrap*functions) that records the method name, region, request params, duration, and either the response or the error — one JSON object per line, so the file can be tailed or processed withjqwhile awsops is still running - The decorator is built on
internal/debuglog’s nil-safe*DebugLog: every logging method is a no-op on a nil receiver, so-debug=false(the default) costs nothing beyond one nil check per client at startup — noif debugconditionals scattered through workflow code - awsops prints the log’s path to stderr once, at startup, when
-debugis set, so the operator knows where totail -fit - The log records AWS resource identifiers and request/response parameters — not credentials, and no customer data ever passes through awsops — but treat a debug log file as containing this team’s infrastructure details (instance IDs, AMI names, security group/subnet IDs) and handle it accordingly (don’t attach it to a public issue, etc.)
- Exception:
ec2:CreateKeyPair’s response carries the new key pair’s unencrypted private key material, which must never reach the debug log even though everything else does — its logging wrapper redactsKeyMaterialto a fixed marker before writing the record, rather than skipping the whole call’s output
Security Considerations
- Never store AWS credentials in the binary or repo; rely on the SDK’s standard credential chain
- Always confirm destructive operations (AMI removal)
- Display instance costs/estimates when creating (if possible)
- Warn about public AMIs vs private AMIs
- 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
- 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
- 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:HeadObjectverification is the actual authorization for the delete step, not a redundant nice-to-have -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- A newly created key pair’s private key material never touches AWS
again after
ec2:CreateKeyPairreturns it — awsops writes it to~/.ssh/<name>.pemwith0600permissions immediately and never logs the raw material anywhere (including-debug’s log; see “Debug Logging” above) - S3 buckets default to
s3:PutPublicAccessBlockfully 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:PutBucketPolicyscoped byAWS: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 - 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
- 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
- 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:
Volume-size time estimates for AMI creation:
Volume size Typical time < 20 GB 5–15 minutes 20–100 GB 15–45 minutes 100–200 GB 45–90 minutes 200+ GB 1.5–3+ hours An Invenio RDM instance (Docker images, PostgreSQL data, OpenSearch indices) is typically 20–60 minutes.
Crash-consistency on running-instance snapshots (
--no-reboot): PostgreSQL and OpenSearch replay their logs on first boot and recover cleanly; Redis session/cache data may be lost (ephemeral by design); Docker container images on disk are unaffected.SSM fstrim pre-snapshot step: if SSM is available on the instance, offer to run
fstrimbefore snapshotting to reduce copy time by skipping already-freed blocks; skip cleanly (not an error) if SSM is unavailable.Prior-snapshot detection: if a volume already has a prior snapshot, note that only changed blocks will be copied and actual time may be shorter than the estimate.
Correction vs.
ami_copy_basic_steps.md: that document states additional attached EBS volumes “must be snapshotted separately” from the root volume. That is not accurate for EBS-backed volumes —create-imagesnapshots every currently-attached EBS volume (per the instance’s block device mapping) into the new AMI by default, and a new instance launched from that AMI gets all of them back automatically. The caveat only applies to ephemeral instance-store volumes, which are never captured in an AMI regardless. Verify this against the actual Invenio RDM instances’ volume layout (root vs. any separately-attached EBS volumes for DB/search data) before implementing Phase 5, since the volume-size time estimate should already be summing across all attached volumes (seegather_volume_info()inec2_ami_manager.bash), but the “must be snapshotted separately” framing should not carry into the Go version’s user-facing guidance.Postgres backup accumulation (grounds Feature 11, Backup Archive & Trim): backups write to
/opt/rdm_sql_backupsas<project>-db-<n>-<project>-<date>.sql.gz, roughly one per day. A live check onnewauthorsfound 87GB accumulated (no rotation at all), at ~980MB/day — this is the concrete, measured cause of the over-provisioned-disk/slow-cloning problem that motivated this whole design conversation.
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:
- Recorded Scripts (“session playbooks”): capture the
sequence of actions taken in an interactive session as an editable,
replayable YAML script — analogous to a “skill” for a language model,
but for this deterministic tool. Values are templated (Go
text/templateover the YAML text before parsing), not just literal captured values, so a saved script can be repurposed against a different instance/AMI/environment. Safety gates (dry-run, confirmation) are always enforced on replay via the same reusable confirmation gate interactive mode uses — never bypassed except by a deliberate, explicit opt-in, and never bypassable at all for anything touchingEnvironment=production. v1 does not build the recorder or replay engine, but Phases 4/5/6/7/8 are structured (params-struct/confirm-gate seam) so this can be added later without reopening that code. SeeDECISIONS.md, “Structure workflows for future record/replay”. - Clone instance for testing, Upgrade with
rollback point, and Bake AMI from cloud-init
(given a base AMI + a cloud-init YAML from
cloud-init-examples, launch → wait forcloud-init status --waitvia SSM →create-image→ terminate, in one guided flow — the same pattern tools like Packer’samazon-ebsbuilder or AWS EC2 Image Builder automate) are all composite sequences built from v1’s primitives. Once Recorded Scripts exist, these likely become example scripts users save and repurpose rather than bespoke Go workflows — recorded here as the motivating use cases, not as separate features to build. - Inline diff against
cloud-init-examples: after Feature 10 decodes an instance/AMI’s cloud-init, fetch a comparison file directly fromcaltechlibrary/cloud-init-examples(picked from a list, via GitHub’s API) and show a unified diff in the tool, instead of exporting for a manualgit diff. Deferred because the repo’s files don’t map 1:1 to this account’sProjecttag values yet (e.g. there’s nocaltechauthors-init.yaml), and it adds a runtime network dependency on GitHub that the rest of this tool doesn’t have — seeDECISIONS.md, “Add Show/Export Cloud-Init as a v1 primitive”. - Edit AMI Description: an AMI’s
Nameis immutable (see Feature 8’s note andDECISIONS.md, “Add Rename Instance as a v1 primitive; AMI Name is immutable”), butDescriptioncan be changed after creation viaec2.ModifyImageAttribute— the closest thing to a “rename” AWS actually allows for an AMI. Not requested for v1; noted so it isn’t lost. - ACM certificate provisioning: Feature 24 lets an
operator attach an alternate domain name to a CloudFront distribution,
but assumes a matching ACM certificate already exists in
us-east-1; requesting and validating (DNS or email) a new certificate is not part of this tool. - CloudFront functions / Lambda@Edge / WAF association: none of CloudFront’s programmable-edge or firewall features are exposed; Feature 24 creates a plain S3-origin distribution only.
- S3 bucket versioning and lifecycle rules: Feature 18 creates a bucket with default settings (no versioning, no lifecycle policy); if this team wants versioned static-website buckets or automatic old-version expiry later, that’s an addition to Feature 18, not a new domain.
Environment=productionsafety-gate extension: Compute’s extra warning before type-to-confirm onEnvironment=productionresources (Features 6, 9) is not extended to Delete Key Pair, bucket/object deletion, or distribution changes in this round (see Feature 26) — a candidate for a later pass once it’s clear which of these new resources actually accumulate a “production” tag in practice.- Recorded Scripts for the new domains: the deferred “session playbooks” idea above was scoped against Compute’s primitives; whether Key Management/S3/CloudFront workflows get the same params-struct/ confirm-gate seam for future record/replay is an open question for when Recorded Scripts itself is actually built, not decided now.