diff --git a/.gitignore b/.gitignore index 601bfd6..34c195e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ loki-agent/ +.codex diff --git a/docs/design-account-rename.md b/docs/design-account-rename.md new file mode 100644 index 0000000..3144e64 --- /dev/null +++ b/docs/design-account-rename.md @@ -0,0 +1,445 @@ +# Design: AWS Account Rename During Lowkey Install + +## Goal + +During Lowkey installation, rename the AWS account to `Loki-` so users can easily identify which AWS accounts have Lowkey deployed. This helps when switching between accounts in the AWS console. + +We use `Loki-` (the internal project name) as the prefix, not `Lowkey-`, for +consistency with existing internal resource naming (SSM paths `/loki/*`, VPC +tags `loki:managed`, repo `loki-agent`, etc.). + +## AWS API + +- **Read**: `aws account get-account-information` → returns `AccountName`, `AccountId`, `AccountCreatedDate` +- **Write**: `aws account put-account-name --account-name "Loki-MyAccount"` +- Account name: 1–50 chars, printable ASCII (AWS allows `[ -;=?-~]+`) +- **We restrict to a safe subset** (`SAFE_NAME_PATTERN`): alphanumeric, + space, hyphen, underscore, dot, plus, equals, at, colon, semicolon, + comma, exclamation, question, hash, parens, brackets, braces, tilde, + caret, slash. + Explicitly **excluded**: `$`, `` ` ``, `"`, `\`, `&`, `|`, `*`, `'`, `%` + (shell metacharacters, format-string hazards, or problematic in logging/quoting) +- Always use `printf '%s'` (not `printf "$var"`) when logging account names + (`%` is excluded from SAFE_NAME_PATTERN but defense-in-depth applies) +- No global uniqueness constraint +- Requires `account:GetAccountInformation` and `account:PutAccountName` permissions +- These are **not** added to `check_permissions()` (which checks CFN/IAM/EC2 only) + since the rename is non-fatal on AccessDenied. Worth adding in a future iteration + for earlier user feedback. +- Changes can take up to 4 hours to propagate across AWS consoles +- `aws account` subcommand requires AWS CLI v2.8+ (2022) + +## Behavior Summary + +| Mode | Default behavior | Override | +|------|-----------------|----------| +| **Interactive** | Prompt user: Rename / Edit / Skip (default: Rename) | `--disable-account-rename` skips prompt entirely | +| **Headless (`-y`)** | Skip rename (safe default for CI) | `--auto-rename-account-enabled` to opt in | + +Rationale: `-y` means "accept installer defaults and deploy" — silently mutating +the org-visible account name would be a surprising side effect for existing CI +pipelines. Headless rename requires explicit opt-in. + +The rename is always **non-fatal** — API failures warn and continue. + +**Telemetry safety rule:** The installer runs under `set -euo pipefail`. +All telemetry calls inside `maybe_rename_account()` MUST be guarded with +`2>/dev/null || true` to ensure a telemetry failure can never abort the +install. This matches the existing pattern used by all `_telem_*` calls +in `main()`. Example: +```bash +_telem_event "install.account_renamed" "$props" 2>/dev/null || true +``` + +## Current Installer Flow (preflight_checks) + +``` +1. verify_aws_credentials() → sets ACCOUNT_ID, CALLER_ARN +2. Display Account/Region/Branch info +3. Warn about AdministratorAccess +4. confirm "Deploy to account?" +5. check_permissions +``` + +## Proposed Flow + +Call `maybe_rename_account()` **after** `run_config_and_review()` — this is +the point where the user has fully committed to deployment in all modes +(normal, simple, with or without `-y`). Placing it inside `preflight_checks` +would risk renaming the account before the user confirms deployment in +simple mode (where `preflight_checks` has no deploy confirmation). + +``` +preflight_checks() + → verify_aws_credentials() + → Display Account/Region/Branch info + → if normal mode: warn + confirm + check_permissions + → else (simple): ok "Using current account and region" +collect_config() +run_config_and_review() → user confirms all settings +**maybe_rename_account()** ← NEW, after user fully commits to deploy +deploy() → including console-deploy early-exit path +``` + +Note: `maybe_rename_account()` must be placed **before** the console-deploy +early-exit (`DEPLOY_CFN_CONSOLE` path calls `exit 0` right after deploy). +All deploy paths — CFN console, CFN CLI, Terraform — get the rename. + +## maybe_rename_account() Logic + +**Safety invariant:** Nothing in this function may abort the install. +All AWS API calls (`aws account`, `aws ssm`) and all telemetry calls +must be guarded with `2>/dev/null || true` or wrapped in `if` blocks. +The function itself should be called from `main()` as: +```bash +maybe_rename_account 2>/dev/null || true +``` + +### Helper: `_emit_rename_telemetry` + +Single point for all rename telemetry. Called at every exit path. +```bash +_emit_rename_telemetry() { + # Usage: _emit_rename_telemetry [skipped_reason] + local renamed="${1:-false}" + local allowed="${2:-false}" + local skipped_reason="${3:-}" + # Defensive: coerce to JSON booleans + [[ "$renamed" == "true" ]] || renamed="false" + [[ "$allowed" == "true" ]] || allowed="false" + local props + props=$(printf '{"renamed":%s,"allowed":%s,"auto_rename_enabled":%s' \ + "$renamed" "$allowed" "$AUTO_RENAME_ACCOUNT") + if [[ -n "$skipped_reason" ]]; then + props+=$(printf ',"skipped_reason":"%s"' "$skipped_reason") + fi + props+='}' + _telem_event "install.account_renamed" "$props" 2>/dev/null || true +} +``` + +### Steps + +``` +1. If --disable-account-rename flag is set: + → info "Account rename disabled via --disable-account-rename" + → _emit_rename_telemetry false false "disabled_flag" + → return + +2. Check `aws account` command is available (older CLI may lack it) + → If not: info "Account rename requires AWS CLI v2.8+, skipping" + → _emit_rename_telemetry false false "cli_missing" + → return + +3. current_name = aws account get-account-information → .AccountName + → If fails: warn "Could not read account name" + → _emit_rename_telemetry false false "api_error" + → return + +4. If current_name already starts with "Loki-" (case-insensitive via + lowercase comparison, e.g. `${current_name,,}` starts with "loki-"): + → ok (using printf '%s' with tr -d '\000-\037'): "Account already named for Loki: " + → If SSM param `/loki/original-account-name` doesn't exist yet: + → Strip "Loki-" prefix (case-insensitive) to get the original name + → If stripped result is empty, use "" as fallback + → Store stripped name as `/loki/original-account-name` + → Store current name as-is as `/loki/installed-account-name` + This preserves restore capability for first-time installs. + On re-installs where SSM already exists, skip SSM writes (no-op). + → _emit_rename_telemetry false false "already_prefixed" + → return (skip — do not re-sanitize or modify existing Loki-prefixed names, + even if they contain characters outside SAFE_NAME_PATTERN) + +5. proposed = "Loki-" + sanitize(current_name) + → Sanitize the original name FIRST (against `SAFE_NAME_PATTERN`), + then prepend "Loki-" to avoid corrupting the prefix + → If current_name is empty: proposed = "Loki-" + (always 17 chars for 12-digit account IDs — well within 50-char limit) + +6. If sanitized name is empty after stripping: fallback to "Loki-" + +7. If proposed > 50 chars: truncate to 50 chars + (Sanitization in step 5 guarantees ASCII-only, so no multi-byte split risk) + → Strip trailing hyphens or spaces from the truncated result + → If result length < 6 (i.e., no meaningful suffix after "Loki-"): fallback to "Loki-" + +8. If --non-interactive / -y (headless): + → If --auto-rename-account-enabled is NOT set: + → info "Headless mode: account rename skipped (pass --auto-rename-account-enabled to enable)" + → _emit_rename_telemetry false false "headless_no_opt_in" + → return + → Auto-apply proposed name (no prompt) + → If name was truncated in step 7: warn "Account name truncated to 50 chars: " + → Jump to step 12 (pass final name to AWS CLI) + +9. If name was truncated in step 7: info "Name truncated to 50 chars" + +10. Show current name, proposed name, and explain: + "This name appears in the AWS console account switcher and billing." + +11. Prompt with gum choose: "Rename to " / "Edit name" / "Skip" + → Rename: use proposed name + → Edit: prompt for custom name, validate against `SAFE_NAME_PATTERN`, + 1–50 char length, and non-empty/non-whitespace. Loop if invalid. The "Loki-" prefix is NOT enforced — + user may choose any valid name (e.g., for org naming conventions). + The prompt pre-fills with the proposed name for convenience. + → Skip: info "Keeping account name: " + → _emit_rename_telemetry false false "user_declined" + → return + +12. Pass final name to AWS CLI using double-quoted variable: --account-name "$final_name" + (No printf '%q' escaping — double-quoting is correct and sufficient; + dangerous characters like $, ", `, \ are excluded by the validation pattern) + +13. aws account put-account-name --account-name "$final_name" + → On 429 (TooManyRequestsException): retry once after 2s sleep + → On success: + → ok (using printf '%s' for safety): "Account renamed to " + → info "May take up to 4 hours to appear everywhere in AWS console" + → _emit_rename_telemetry true true + → Store original name in SSM: /loki/original-account-name + → Store installed name in SSM: /loki/installed-account-name + → SSM writes are non-fatal: if they fail, warn and continue + (uninstall falls back to prefix-stripping) + → On failure (AccessDenied, SCP block, throttle, etc): + → warn "Could not rename account: . Deployment will continue." + → _emit_rename_telemetry false true "api_error" + → return (non-fatal) +``` + +## Edge Cases + +| Scenario | Behavior | +|----------|----------| +| Already starts with "Loki-" / "loki-" | Skip with ok message | +| Name + "Loki-" prefix > 50 chars | Truncate to 50; offer Edit in interactive, auto-apply in headless | +| `--non-interactive` / `-y` mode | Skip rename (safe default) | +| `-y` + `--auto-rename-account-enabled` | Auto-rename | +| `-y` + `--disable-account-rename` | Skip rename entirely (redundant but explicit) | +| `--test` mode | Not applicable — installer exits before rename is reached | +| API call fails (permissions/SCP) | warn + continue — non-fatal | +| `get-account-information` fails | warn + skip | +| `aws account` command missing | info + skip (old CLI) | +| Empty account name from API | Fallback to "Loki-" | +| Name contains non-ASCII chars | Strip to printable ASCII before prefixing | +| User input via Edit exceeds 50 chars | Reject and re-prompt | +| Organizations member account + SCP blocking | AccessDenied → warn + continue | +| Existing variants: "Lowkey-*", "Loki " (space) | Not recognized — only "Loki-" (with hyphen) is detected, case-insensitively | +| Concurrent installs on same account | Last writer wins for both account name and SSM state; second install's original-name SSM value overwrites first's. Cross-region dual-deploys store SSM in different regional stores — uninstaller may find wrong region's state (documented known gap) | +| TooManyRequestsException (429) | Retry once after 2s, then warn + continue | + +## CLI Flags + +Add to installer: +- `--auto-rename-account-enabled` — Enable auto-rename in headless (`-y`) mode +- `--disable-account-rename` — Skip account rename entirely (suppresses interactive prompt too) + +Parsing: add to the existing `while [[ $# -gt 0 ]]` / `case` block: +```bash +AUTO_RENAME_ACCOUNT=false +DISABLE_ACCOUNT_RENAME=false +# ... +--auto-rename-account-enabled) AUTO_RENAME_ACCOUNT=true; shift ;; +--disable-account-rename) DISABLE_ACCOUNT_RENAME=true; shift ;; +``` + +## Uninstall Approach (Hybrid) + +During install, store state in SSM Parameter Store (using `--region "$DEPLOY_REGION"` +for consistency with existing SSM usage in the installer): +```bash +aws ssm put-parameter --name "/loki/original-account-name" \ + --value "$current_name" --type String --overwrite --region "$DEPLOY_REGION" +aws ssm put-parameter --name "/loki/installed-account-name" \ + --value "$final_name" --type String --overwrite --region "$DEPLOY_REGION" +``` + +During uninstall (`maybe_restore_account_name()`): + +The uninstaller must locate the SSM parameters in the correct region. Strategy: +1. Try `SCAN_REGION` (user-provided uninstall region) first +2. If not found, try the default configured region (`aws configure get region`); + if no default region is configured, skip this step +3. If still not found, fall through to heuristic fallback (step 5) + +Known limitation: if the user deployed to a non-default region and uninstalls +from a different region, SSM parameters won't be found and may restore the +wrong original name. The heuristic fallback handles this gracefully. A future improvement could tag the Loki VPC with the +SSM region for cross-region discovery. + +Headless uninstall behavior: never auto-restore. Restoring the account name +requires interactive confirmation. If a headless `--non-interactive` flag is +added to the uninstaller in the future, account name restore should be skipped +unless an explicit `--restore-account-name` flag is passed. + +1. Read `/loki/installed-account-name` from SSM (trying regions as above) +2. Read current account name via `get-account-information` +3. If current name equals the installed name (hasn't been manually changed): + → Read `/loki/original-account-name` from SSM + → The stored original is NOT re-validated against SAFE_NAME_PATTERN. + It came from the AWS API, so it's already a valid AWS account name. + Re-validating could reject a legitimate restore. + → Offer to restore: "Restore account name from '' to ''?" +4. If current name differs from installed name: + → info "Account name was changed after install, skipping restore" +5. If SSM parameters don't exist (fallback): + → If current name starts with "Loki-" (case-insensitive, same as install step 4): + → Propose stripping prefix to get the approximate original name + → Note: this is a heuristic — the stripped result may differ from the + true original if the installer sanitized the name during install + → Offer to restore, but never auto-restore + → Note: this heuristic may match names not set by this installer — + that's acceptable since it only offers, never auto-applies +6. Clean up SSM parameters only after a **successful restore**, not after skip. + If the user declines, SSM parameters are preserved for future uninstall attempts. + +## Security Notes + +- All user input and generated names are validated against `SAFE_NAME_PATTERN`, + a strict subset of AWS's `[ -;=?-~]+` that excludes shell metacharacters + and characters problematic in logging/URL/quoting contexts: + `$`, `` ` ``, `"`, `\`, `&`, `|`, `*`, `'`, `%` +- Double-quote variables in AWS CLI invocations: `--account-name "$final_name"` +- Never use `eval` with user-provided names +- Use `printf '%s'` for all display of account names (defense-in-depth) +- Use `tr -d '\000-\037'` to strip control characters before printing names +- **Uninstall exception**: The restore path passes the SSM-stored original name + to `put-account-name` WITHOUT re-validating against SAFE_NAME_PATTERN. + This is safe because: (a) the value came from the AWS API originally, + (b) it's passed via double-quoted `"$var"` with no `eval`, and + (c) re-validating could reject a legitimate restore. +- The rename is purely cosmetic — no security implications for the deployment + +## Telemetry + +### Install Beacon: `account_rename_enabled` field + +Add `account_rename_enabled` (boolean) to the `/v1/install` beacon payload, +alongside `is_test`. This surfaces the flag on every install beacon so +dashboards can filter/segment without waiting for the event batch. + +**Installer change** (`_telem_send_install_beacon` in install.sh): +```bash +# Add to the JSON body, after is_test: +,"account_rename_enabled":${AUTO_RENAME_ACCOUNT:-false} +``` + +**Backend change** (`lambda-shared/validate.py` → `validate_install()`): +```python +# In the output dict construction, after "outcome": +if isinstance(env.get("account_rename_enabled"), bool): + out["account_rename_enabled"] = env["account_rename_enabled"] +``` + +**Backend change** (`lambda-install/handler.py`): +```python +# Add to the row dict written to Firehose, after "outcome": +"account_rename_enabled": env.get("account_rename_enabled"), + +# Add to notify_payload: +notify_payload["account_rename_enabled"] = env.get("account_rename_enabled", False) +``` + +### Event: `install.account_renamed` + +Emitted via the `/v1/ingest` batch path (queued by `_telem_event` in +installer, flushed by `_telem_flush` at install end). + +Props: +- `renamed`: boolean — did the rename actually happen? +- `allowed`: boolean — did the user allow the rename? + - Interactive: true if user chose "Rename" or "Edit", false if "Skip" + - Headless: true if `--auto-rename-account-enabled` was passed + - false for all other skip paths (disabled_flag, cli_missing, api_error, already_prefixed) +- `auto_rename_enabled`: boolean — was `--auto-rename-account-enabled` passed? +- `skipped_reason`: one of (only when renamed=false): + - `"already_prefixed"` — account already starts with "Loki-" + - `"user_declined"` — user chose "Skip" in interactive prompt + - `"disabled_flag"` — `--disable-account-rename` was passed + - `"headless_no_opt_in"` — headless mode without `--auto-rename-account-enabled` + - `"api_error"` — AWS API call failed + - `"cli_missing"` — `aws account` subcommand not available + +### Backend Changes Required (loki-dashboard) + +The `install.account_renamed` event must be registered in the telemetry +backend before the installer starts emitting it. Changes needed in +`inceptionstack/loki-dashboard` → `infra/loki-telemetry/`: + +**1. `lambda-shared/validate.py` — EVENT_CATALOG + prop validators:** + +(This is the canonical source; `build-lambdas.sh` copies it to +`lambda-ingest/_shared/` and `lambda-install/_shared/`.) +```python +# Add to EVENT_CATALOG: +"install.account_renamed": {"renamed", "allowed", "auto_rename_enabled", "skipped_reason"}, + +# Add allowed skipped_reason values: +ALLOWED_ACCOUNT_RENAME_SKIP = { + "already_prefixed", "user_declined", "disabled_flag", + "headless_no_opt_in", "api_error", "cli_missing", +} + +# Add to _PROP_VALIDATORS: +("install.account_renamed", "renamed"): lambda v: isinstance(v, bool), +("install.account_renamed", "allowed"): lambda v: isinstance(v, bool), +("install.account_renamed", "auto_rename_enabled"): lambda v: isinstance(v, bool), +("install.account_renamed", "skipped_reason"): "account_rename_skip", + +# Add to _scrub_prop: +if validator == "account_rename_skip": + return value if value in ALLOWED_ACCOUNT_RENAME_SKIP else None +``` + +**2. `lambda-ingest/handler.py` — Telegram notification enrichment:** +```python +# Add alongside existing install.* notification handling: +elif name == "install.account_renamed": + notify_payload["account_renamed"] = p.get("renamed", False) + notify_payload["account_rename_allowed"] = p.get("allowed", False) + if p.get("skipped_reason"): + notify_payload["rename_skip_reason"] = p["skipped_reason"] + if p.get("auto_rename_enabled"): + notify_payload["auto_rename_enabled"] = True +``` + +**3. Build step:** +Run `build-lambdas.sh` to copy `lambda-shared/` into each Lambda's `_shared/` +directory. + +**⚠️ Deployment order:** Deploy `loki-dashboard` backend changes **first** (so the +API accepts the new event), then deploy the installer update. If deployed in +reverse order, the new event gets silently dropped (forward-compat: unknown +events → drop). **This is a cross-service ordering constraint — violating it +causes data loss for rename telemetry.** + +Note: The Python snippets above are illustrative. Verify against the current +`loki-dashboard` codebase at implementation time — the dashboard code may have +changed since this design was written. + +## Implementation Scope + +Checklist of files/repos that need changes: + +### `inceptionstack/lowkey` (this repo) +- [x] `install.sh`: Add `maybe_rename_account()`, `_emit_rename_telemetry()`, + flag parsing (`--auto-rename-account-enabled`, `--disable-account-rename`), + call site in `main()` after `run_config_and_review()` +- [x] `install.sh`: Add `account_rename_enabled` field to `_telem_send_install_beacon` +- [x] `install.sh` `--help` text: Document new flags +- [x] `docs/reference/telemetry-v1.schema.json`: Add `account_rename_enabled` to beacon, + `install.account_renamed` to EventName enum +- [x] `docs/reference/telemetry-schema.mdx`: Document new beacon field and event +- [x] `docs/reference/cli.mdx`: Document new flags +- [x] Tests: 47 test cases in `tests/test-account-rename.sh` +- [ ] `uninstall.sh`: Add `maybe_restore_account_name()` — **significant scope, + tracked as follow-up:** currently has zero account/SSM-restore code. +- [ ] `docs/quickstart.mdx`: Mention account rename in install flow overview (follow-up) + +### `inceptionstack/loki-dashboard` (separate repo) +- [ ] `lambda-shared/validate.py`: Register `install.account_renamed` event, + add `ALLOWED_ACCOUNT_RENAME_SKIP`, prop validators +- [ ] `lambda-shared/validate.py`: Add `account_rename_enabled` to install beacon +- [ ] `lambda-ingest/handler.py`: Telegram notification enrichment +- [ ] `lambda-install/handler.py`: Add `account_rename_enabled` to Firehose row +- [ ] Run `build-lambdas.sh` and deploy diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 535cfe7..945d0c6 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -17,6 +17,9 @@ This page lists every flag accepted by `install.sh` (the script you get from `in | `--method ` | Pre-select deploy method. Default: CFN via CLI. | | `--kiro-from-secret ` | Secrets Manager id/arn whose `SecretString` is the Kiro API key (kiro-cli pack, headless mode). | | `--debug-in-repo` | Dev-only: run the installer from the current working directory instead of downloading. | +| `--test`, `--dry-run` | Run installer end-to-end without provisioning AWS resources. Telemetry tagged `is_test`. | +| `--auto-rename-account-enabled` | Enable auto-rename of AWS account to `Loki-` in headless (`-y`) mode. No-op in interactive mode (user is always prompted). | +| `--disable-account-rename` | Skip account rename entirely. Suppresses the interactive rename prompt too. | | `--help`, `-h` | Show the help text and exit. | ## Canonical non-interactive invocations diff --git a/docs/reference/telemetry-schema.mdx b/docs/reference/telemetry-schema.mdx index f4e1d28..945201f 100644 --- a/docs/reference/telemetry-schema.mdx +++ b/docs/reference/telemetry-schema.mdx @@ -59,6 +59,7 @@ Sent to `POST /v1/install`. One envelope per outcome transition (`started`, `com "outcome": "completed", "duration_ms": 412380, "is_test": false, + "account_rename_enabled": false, "failure_step": null, "failure_class": null } @@ -81,6 +82,7 @@ Sent to `POST /v1/install`. One envelope per outcome transition (`started`, `com | `outcome` | string enum | yes | `started` \| `completed` \| `failed`. | | `duration_ms` | integer | yes | ms since install start. `0` on `started`. | | `is_test` | boolean | yes | `true` when installer ran with `--test` / `TEST_MODE=true`. Backends MUST exclude these from funnels. | +| `account_rename_enabled` | boolean | no | `true` when `--auto-rename-account-enabled` was passed. Surfaces headless opt-in without waiting for event batch. Absent on installers < v0.5.117. | | `failure_step` | string \| null | conditional | `null` unless `outcome: failed`. Short step identifier, e.g. `aws_cli_check`, `cfn_deploy`, `bootstrap_timeout`. Max 64 chars. | | `failure_class` | string \| null | conditional | `null` unless `outcome: failed`. Machine-friendly class, e.g. `exit_1`, `exit_130`. Max 64 chars. | @@ -131,7 +133,7 @@ Sent to `POST /v1/ingest`. Exactly **once per install run**, at the end. Contain { "t": "2026-04-27T12:23:58Z", "name": "install.method_selected", "props": { "method": "cfn", "region": "us-east-1" } }, { "t": "2026-04-27T12:24:10Z", "name": "install.deploy_started", "props": { "method": "cfn", "region": "us-east-1", "pack": "openclaw" } }, { "t": "2026-04-27T12:29:50Z", "name": "install.deploy_completed","props": { "duration_ms": 340000, "method": "cfn" } }, - { "t": "2026-04-27T12:30:11Z", "name": "install.bootstrap_completed","props":{ "instance_id": "i-0abc..." } }, + { "t": "2026-04-27T12:30:11Z", "name": "install.bootstrap_completed","props":{ "account_id": "123456789012" } }, { "t": "2026-04-27T12:30:14Z", "name": "install.completed", "props": { "duration_ms": 412380, "pack": "openclaw", "method": "cfn", "region": "us-east-1" } } ] } @@ -175,7 +177,8 @@ Backends MUST reject events whose `name` is not on this allowlist. This prevents | `install.method_selected` | `{method, region}` | User picked deploy method (cfn/terraform/console) + region. | | `install.deploy_started` | `{method, region, pack}` | CFN/TF apply kicked off. | | `install.deploy_completed` | `{duration_ms, method}` | Stack reached `CREATE_COMPLETE` or equivalent. | -| `install.bootstrap_completed` | `{instance_id}` | EC2 instance finished userdata bootstrap. | +| `install.bootstrap_completed` | `{account_id}` | EC2 instance finished userdata bootstrap. | +| `install.account_renamed` | `{renamed, allowed, auto_rename_enabled, skipped_reason}` | Account rename outcome — whether rename happened, user allowed it, and skip reason if applicable. | | `install.completed` | `{duration_ms, pack, method, region}` | Full install success. | | `install.failed` | `{duration_ms, exit_code, step, pack, method}` | Installer exited non-zero. | diff --git a/docs/reference/telemetry-v1.schema.json b/docs/reference/telemetry-v1.schema.json index 8887877..e857c34 100644 --- a/docs/reference/telemetry-v1.schema.json +++ b/docs/reference/telemetry-v1.schema.json @@ -27,6 +27,7 @@ "outcome": { "enum": ["started", "completed", "failed"] }, "duration_ms": { "type": "integer", "minimum": 0 }, "is_test": { "type": "boolean" }, + "account_rename_enabled": { "type": "boolean" }, "failure_step": { "type": ["string", "null"], "maxLength": 64 }, "failure_class": { "type": ["string", "null"], "maxLength": 64 } }, @@ -112,6 +113,7 @@ "install.deploy_started", "install.deploy_completed", "install.bootstrap_completed", + "install.account_renamed", "install.completed", "install.failed", "first_run", diff --git a/install.sh b/install.sh index 6cde6a8..035a8d4 100755 --- a/install.sh +++ b/install.sh @@ -438,7 +438,7 @@ _telem_send_install_beacon() { local body body=$(cat < in headless (-y) mode + --disable-account-rename Skip account rename entirely -h, --help Show this help and exit Examples: @@ -2551,6 +2558,255 @@ show_complete() { safe_cleanup_dir "${TF_WORKDIR:-}" "temp Terraform workdir" '/tmp/*' } +# ============================================================================ +# Account Rename +# ============================================================================ + +# SAFE_NAME_PATTERN: printable ASCII subset excluding shell metacharacters. +# Keeps: alnum, space, hyphen, underscore, dot, plus, equals, at, colon, +# semicolon, comma, exclamation, question, hash, parens, brackets, +# braces, tilde, caret, slash. +# Strips: $, `, ", \, &, |, *, ', % and control chars. +_sanitize_account_name() { + local name="$1" + # Strip control chars, then strip excluded metacharacters + printf '%s' "$name" \ + | tr -d '\000-\037' \ + | sed 's/[\$`"\\&|*'\''%]//g' +} + +_emit_rename_telemetry() { + # Usage: _emit_rename_telemetry [skipped_reason] + local renamed="${1:-false}" + local allowed="${2:-false}" + local skipped_reason="${3:-}" + # Defensive: coerce to JSON booleans + [[ "$renamed" == "true" ]] || renamed="false" + [[ "$allowed" == "true" ]] || allowed="false" + local auto_val="false" + [[ "$AUTO_RENAME_ACCOUNT" == "true" ]] && auto_val="true" + local props + props=$(printf '{"renamed":%s,"allowed":%s,"auto_rename_enabled":%s' \ + "$renamed" "$allowed" "$auto_val") + # Note: skipped_reason values are always hardcoded string literals from callers. + # Do not pass user input here — the printf pattern does not escape for JSON. + if [[ -n "$skipped_reason" ]]; then + props+=$(printf ',"skipped_reason":"%s"' "$skipped_reason") + fi + props+='}' + _telem_event "install.account_renamed" "$props" 2>/dev/null || true +} + +maybe_rename_account() { + # Step 1: Check disable flag + if [[ "$DISABLE_ACCOUNT_RENAME" == "true" ]]; then + [[ "$AUTO_RENAME_ACCOUNT" == "true" ]] && \ + info "Both --auto-rename-account-enabled and --disable-account-rename set; rename disabled" + info "Account rename disabled via --disable-account-rename" + _emit_rename_telemetry false false "disabled_flag" + return 0 + fi + + # Step 2: Check aws account subcommand availability + if ! aws account help >/dev/null 2>&1; then + info "Account rename requires AWS CLI v2.8+, skipping" + _emit_rename_telemetry false false "cli_missing" + return 0 + fi + + # Step 3: Read current account name + local account_info current_name + if ! account_info=$(aws account get-account-information --output json 2>&1); then + warn "Could not read account name" + _emit_rename_telemetry false false "api_error" + return 0 + fi + current_name=$(printf '%s' "$account_info" | jq -r '.AccountName // ""' 2>/dev/null || printf '') + + # Step 4: Already prefixed? + if _account_already_prefixed "$current_name"; then + return 0 + fi + + # Step 5-7: Build proposed name (sets _RENAME_PROPOSED, _RENAME_WAS_TRUNCATED) + _build_proposed_name "$current_name" + local proposed="$_RENAME_PROPOSED" + local was_truncated="$_RENAME_WAS_TRUNCATED" + + # Step 8-11: Resolve final name (headless or interactive) + # Sets _RENAME_FINAL_NAME or returns 0 with telemetry emitted on skip. + if ! _resolve_final_name "$proposed" "$current_name" "$was_truncated"; then + return 0 # user skipped or headless without opt-in (telemetry already emitted) + fi + + # Step 12-13: Apply rename via AWS API + _apply_account_rename "$_RENAME_FINAL_NAME" "$current_name" +} + +# Returns 0 (true) if already prefixed and handled; 1 otherwise. +_account_already_prefixed() { + local current_name="$1" + local lower_name + lower_name=$(printf '%s' "$current_name" | tr '[:upper:]' '[:lower:]') + if [[ "$lower_name" == loki-* ]]; then + local display_name + display_name=$(printf '%s' "$current_name" | tr -d '\000-\037') + ok "Account already named for Loki: $(printf '%s' "$display_name")" + # Write SSM params if they don't exist yet (first install with pre-existing prefix). + # Note: stripped_original is a best-guess — if account was manually named + # "LOKI-Foo", we store "Foo" but the true pre-Loki original is unknown. + if ! aws ssm get-parameter --name "/loki/original-account-name" \ + --region "$DEPLOY_REGION" --output text >/dev/null 2>&1; then + local stripped_original="${current_name:5}" # strip 5-char prefix (Loki-) + [[ -n "$stripped_original" ]] || stripped_original="$ACCOUNT_ID" + aws ssm put-parameter --name "/loki/original-account-name" \ + --value "$stripped_original" --type String --overwrite \ + --region "$DEPLOY_REGION" >/dev/null 2>&1 || true + aws ssm put-parameter --name "/loki/installed-account-name" \ + --value "$current_name" --type String --overwrite \ + --region "$DEPLOY_REGION" >/dev/null 2>&1 || true + fi + _emit_rename_telemetry false false "already_prefixed" + return 0 + fi + return 1 +} + +# Builds the proposed "Loki-" name. +# Sets _RENAME_PROPOSED and _RENAME_WAS_TRUNCATED. +_build_proposed_name() { + local current_name="$1" + local sanitized + _RENAME_WAS_TRUNCATED=false + _RENAME_PROPOSED="" + + sanitized=$(_sanitize_account_name "$current_name") + if [[ -z "$sanitized" ]]; then + _RENAME_PROPOSED="Loki-${ACCOUNT_ID}" + else + _RENAME_PROPOSED="Loki-${sanitized}" + fi + + if [[ ${#_RENAME_PROPOSED} -gt 50 ]]; then + _RENAME_PROPOSED="${_RENAME_PROPOSED:0:50}" + _RENAME_PROPOSED=$(printf '%s' "$_RENAME_PROPOSED" | sed 's/[- ]*$//') + _RENAME_WAS_TRUNCATED=true + fi + if [[ ${#_RENAME_PROPOSED} -lt 6 ]]; then + _RENAME_PROPOSED="Loki-${ACCOUNT_ID}" + _RENAME_WAS_TRUNCATED=false + fi +} + +# Resolves the final name via headless auto-apply or interactive prompt. +# Sets _RENAME_FINAL_NAME on success (return 0). +# Returns 1 if user skipped or headless without opt-in (telemetry emitted inside). +_resolve_final_name() { + local proposed="$1" current_name="$2" was_truncated="$3" + _RENAME_FINAL_NAME="" + + if [[ "$AUTO_YES" == "true" ]]; then + # Headless mode + if [[ "$AUTO_RENAME_ACCOUNT" != "true" ]]; then + info "Headless mode: account rename skipped (pass --auto-rename-account-enabled to enable)" + _emit_rename_telemetry false false "headless_no_opt_in" + return 1 + fi + _RENAME_FINAL_NAME="$proposed" + if [[ "$was_truncated" == "true" ]]; then + warn "Account name truncated to 50 chars: $(printf '%s' "$_RENAME_FINAL_NAME")" + fi + else + # Interactive mode + if [[ "$was_truncated" == "true" ]]; then + info "Name truncated to 50 chars" + fi + echo "" + info "Current account name: $(printf '%s' "$current_name")" + info "Proposed name: $(printf '%s' "$proposed")" + info "This name appears in the AWS console account switcher and billing." + echo "" + + local choice + choice=$($GUM choose --header "Rename AWS account?" \ + "Rename to $proposed" "Edit name" "Skip" 2>/dev/null || echo "Skip") + + # Use if/elif instead of case to avoid glob pattern matching on $proposed + # (account names may contain ?, [], which are bash glob characters) + if [[ "$choice" == "Rename to $proposed" ]]; then + _RENAME_FINAL_NAME="$proposed" + elif [[ "$choice" == "Edit name" ]]; then + local edit_attempts=0 + while true; do + edit_attempts=$((edit_attempts + 1)) + if [[ $edit_attempts -gt 3 ]]; then + warn "Too many invalid attempts, skipping rename" + _emit_rename_telemetry false false "user_declined" + return 1 + fi + _RENAME_FINAL_NAME=$($GUM input --placeholder "Enter account name (1-50 chars)" \ + --value "$proposed" 2>/dev/null || echo "") + # Validate against SAFE_NAME_PATTERN — reject (don't silently mutate) + local sanitized_check + sanitized_check=$(_sanitize_account_name "$_RENAME_FINAL_NAME") + if [[ "$sanitized_check" != "$_RENAME_FINAL_NAME" ]]; then + warn "Name contains invalid characters (no \$, \`, \", \\, &, |, *, ', %)" + continue + fi + if [[ -z "$_RENAME_FINAL_NAME" || "${_RENAME_FINAL_NAME// /}" == "" ]]; then + warn "Name cannot be empty or whitespace-only" + continue + fi + if [[ ${#_RENAME_FINAL_NAME} -gt 50 ]]; then + warn "Name must be 50 characters or less (got ${#_RENAME_FINAL_NAME})" + continue + fi + break + done + else + info "Keeping account name: $(printf '%s' "$current_name")" + _emit_rename_telemetry false false "user_declined" + return 1 + fi + fi + return 0 +} + +# Calls put-account-name with retry, writes SSM params. All non-fatal. +_apply_account_rename() { + local final_name="$1" current_name="$2" + local put_err + + if ! put_err=$(aws account put-account-name --account-name "$final_name" 2>&1); then + if [[ "$put_err" == *"TooManyRequestsException"* || "$put_err" == *"429"* ]]; then + sleep 2 + if ! put_err=$(aws account put-account-name --account-name "$final_name" 2>&1); then + warn "Could not rename account: $(printf '%s' "$put_err"). Deployment will continue." + _emit_rename_telemetry false true "api_error" + return 0 + fi + else + warn "Could not rename account: $(printf '%s' "$put_err"). Deployment will continue." + _emit_rename_telemetry false true "api_error" + return 0 + fi + fi + + ok "Account renamed to $(printf '%s' "$final_name")" + info "May take up to 4 hours to appear everywhere in AWS console" + _emit_rename_telemetry true true + + # Store original + installed names in SSM (non-fatal) + aws ssm put-parameter --name "/loki/original-account-name" \ + --value "$current_name" --type String --overwrite \ + --region "$DEPLOY_REGION" >/dev/null 2>&1 || \ + warn "Could not store original account name in SSM (non-fatal)" + aws ssm put-parameter --name "/loki/installed-account-name" \ + --value "$final_name" --type String --overwrite \ + --region "$DEPLOY_REGION" >/dev/null 2>&1 || \ + warn "Could not store installed account name in SSM (non-fatal)" +} + # ============================================================================ # Main # ============================================================================ @@ -2611,6 +2867,7 @@ main() { run_config_and_review # steps 2-4 (config → review) _telem_pack_selected 2>/dev/null || true _telem_method_selected 2>/dev/null || true + maybe_rename_account 2>/dev/null || true # Console deploy exits early (no clone, no bootstrap wait) if [[ "$DEPLOY_METHOD" == "$DEPLOY_CFN_CONSOLE" ]]; then diff --git a/scripts/pre-commit b/scripts/pre-commit index f92b52d..73a0cb0 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -31,6 +31,20 @@ echo "" printf "${BOLD}Pre-commit: running tests...${NC}\n" echo "" +# Run git-secrets scan on staged files (if available) +if command -v git-secrets &>/dev/null; then + printf "${CYAN}▸${NC} Scanning for secrets..." + if git secrets --pre_commit_hook; then + printf " ${GREEN}✓${NC}\n" + else + printf " ${RED}✗${NC}\n" + echo "" + printf "${RED}✗ git-secrets found prohibited patterns — commit blocked.${NC}\n" + printf " See output above. Use .gitallowed for false positives.\n\n" + exit 1 + fi +fi + # Discover and run all test scripts (same as CI) while IFS= read -r test_script; do [[ -f "$test_script" ]] && run_test "$test_script" diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh index 94ad0e2..41db69f 100755 --- a/scripts/setup-hooks.sh +++ b/scripts/setup-hooks.sh @@ -18,6 +18,15 @@ mkdir -p "${HOOKS_DIR}" cp "${REPO_ROOT}/scripts/pre-commit" "${HOOKS_DIR}/pre-commit" chmod +x "${HOOKS_DIR}/pre-commit" +# Install git-secrets hooks (if git-secrets is available) +if command -v git-secrets &>/dev/null; then + ( cd "${REPO_ROOT}" && git secrets --register-aws 2>/dev/null || true ) + echo "✓ git-secrets AWS patterns registered" +else + echo "⚠ git-secrets not found — install for local secret scanning:" + echo " brew install git-secrets OR https://github.com/awslabs/git-secrets#installing-git-secrets" +fi + echo "✓ Git hooks installed:" echo " pre-commit → runs all unit tests before commit" echo "" diff --git a/tests/test-account-rename.sh b/tests/test-account-rename.sh new file mode 100755 index 0000000..f198430 --- /dev/null +++ b/tests/test-account-rename.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# tests/test-account-rename.sh — tests for maybe_rename_account() and helpers +# Run: bash tests/test-account-rename.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +INSTALL_SH="${SCRIPT_DIR}/install.sh" + +PASS=0 +FAIL=0 + +# ---- Helpers ---------------------------------------------------------------- +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo " ✓ $desc"; PASS=$((PASS + 1)) + else + echo " ✗ $desc"; echo " expected: $expected"; echo " actual: $actual"; FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local desc="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo " ✓ $desc"; PASS=$((PASS + 1)) + else + echo " ✗ $desc"; echo " missing: $needle"; FAIL=$((FAIL + 1)) + fi +} + +assert_not_contains() { + local desc="$1" needle="$2" haystack="$3" + if [[ "$haystack" != *"$needle"* ]]; then + echo " ✓ $desc"; PASS=$((PASS + 1)) + else + echo " ✗ $desc"; echo " should not contain: $needle"; FAIL=$((FAIL + 1)) + fi +} + +# ---- Extract functions from install.sh for unit testing --------------------- +# We source specific functions by extracting them. This avoids running the +# entire installer which has side effects. +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# Extract the functions we need into a sourceable file +cat > "$TMPDIR/functions.sh" << 'EXTRACT' +#!/usr/bin/env bash +set -euo pipefail + +# Minimal stubs for dependencies +NC="" BOLD="" DIM="" RED="" GREEN="" YELLOW="" CYAN="" BLUE="" MAGENTA="" +GUM="echo" +ACCOUNT_ID="123456789012" +DEPLOY_REGION="us-east-1" +AUTO_YES=false +AUTO_RENAME_ACCOUNT=false +DISABLE_ACCOUNT_RENAME=false + +# Stub ok/info/warn to capture output +_OUTPUT="" +ok() { _OUTPUT+="[ok] $*"$'\n'; } +info() { _OUTPUT+="[info] $*"$'\n'; } +warn() { _OUTPUT+="[warn] $*"$'\n'; } + +# Stub telemetry +_TELEM_EVENTS=() +_telem_event() { + _TELEM_EVENTS+=("$1|$2") +} + +# Stub AWS CLI — override per test +_AWS_ACCOUNT_INFO_RESULT="" +_AWS_ACCOUNT_INFO_EXIT=0 +_AWS_ACCOUNT_PUT_EXIT=0 +_AWS_SSM_GET_EXIT=1 # default: param not found +_AWS_SSM_PUT_EXIT=0 +aws() { + case "$1 $2" in + "account get-account-information") + if [[ $_AWS_ACCOUNT_INFO_EXIT -ne 0 ]]; then return $_AWS_ACCOUNT_INFO_EXIT; fi + echo "$_AWS_ACCOUNT_INFO_RESULT" + ;; + "account put-account-name") + return $_AWS_ACCOUNT_PUT_EXIT + ;; + "ssm get-parameter") + return $_AWS_SSM_GET_EXIT + ;; + "ssm put-parameter") + return $_AWS_SSM_PUT_EXIT + ;; + "account help") + return 0 + ;; + *) + return 0 + ;; + esac +} + +EXTRACT + +# Initialize module-level variables used by extracted functions +echo '_RENAME_WAS_TRUNCATED=false' >> "$TMPDIR/functions.sh" +echo '_RENAME_FINAL_NAME=""' >> "$TMPDIR/functions.sh" +echo '_RENAME_PROPOSED=""' >> "$TMPDIR/functions.sh" + +# Now extract the actual functions from install.sh +# _sanitize_account_name +sed -n '/^_sanitize_account_name() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# _emit_rename_telemetry +sed -n '/^_emit_rename_telemetry() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# _account_already_prefixed +sed -n '/^_account_already_prefixed() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# _build_proposed_name +sed -n '/^_build_proposed_name() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# _resolve_final_name +sed -n '/^_resolve_final_name() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# _apply_account_rename +sed -n '/^_apply_account_rename() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" +# maybe_rename_account +sed -n '/^maybe_rename_account() {/,/^}/p' "$INSTALL_SH" >> "$TMPDIR/functions.sh" + +# ============================================================================ +echo "── _sanitize_account_name ──" +# ============================================================================ + +test_sanitize_passthrough() { + source "$TMPDIR/functions.sh" + local result + result=$(_sanitize_account_name "MyAccount-123") + assert_eq "alphanumeric + hyphen passes through" "MyAccount-123" "$result" +}; test_sanitize_passthrough + +test_sanitize_strips_shell_metacharacters() { + source "$TMPDIR/functions.sh" + local result + result=$(_sanitize_account_name 'My$Account&"test') + assert_eq "strips \$, &, \"" "MyAccounttest" "$result" +}; test_sanitize_strips_shell_metacharacters + +test_sanitize_keeps_safe_special_chars() { + source "$TMPDIR/functions.sh" + local result + result=$(_sanitize_account_name "My Account_v2.0+test=ok") + assert_eq "keeps space, underscore, dot, plus, equals" "My Account_v2.0+test=ok" "$result" +}; test_sanitize_keeps_safe_special_chars + +test_sanitize_strips_control_chars() { + source "$TMPDIR/functions.sh" + local result + # shellcheck disable=SC2059 + result=$(_sanitize_account_name $'My\x01\x1FAccount') + assert_eq "strips control characters" "MyAccount" "$result" +}; test_sanitize_strips_control_chars + +test_sanitize_empty_result() { + source "$TMPDIR/functions.sh" + local result + result=$(_sanitize_account_name '$$$') + assert_eq "all-invalid chars returns empty" "" "$result" +}; test_sanitize_empty_result + +# ============================================================================ +echo "" +echo "── _emit_rename_telemetry ──" +# ============================================================================ + +test_emit_success() { + source "$TMPDIR/functions.sh" + AUTO_RENAME_ACCOUNT=true + _TELEM_EVENTS=() + _emit_rename_telemetry true true + assert_eq "event name" "install.account_renamed" "${_TELEM_EVENTS[0]%%|*}" + assert_contains "renamed=true" '"renamed":true' "${_TELEM_EVENTS[0]}" + assert_contains "allowed=true" '"allowed":true' "${_TELEM_EVENTS[0]}" + assert_contains "auto_rename_enabled=true" '"auto_rename_enabled":true' "${_TELEM_EVENTS[0]}" + assert_not_contains "no skipped_reason" 'skipped_reason' "${_TELEM_EVENTS[0]}" +}; test_emit_success + +test_emit_skipped() { + source "$TMPDIR/functions.sh" + AUTO_RENAME_ACCOUNT=false + _TELEM_EVENTS=() + _emit_rename_telemetry false false "user_declined" + assert_contains "renamed=false" '"renamed":false' "${_TELEM_EVENTS[0]}" + assert_contains "allowed=false" '"allowed":false' "${_TELEM_EVENTS[0]}" + assert_contains "skipped_reason" '"skipped_reason":"user_declined"' "${_TELEM_EVENTS[0]}" +}; test_emit_skipped + +test_emit_coerces_invalid_booleans() { + source "$TMPDIR/functions.sh" + AUTO_RENAME_ACCOUNT=false + _TELEM_EVENTS=() + _emit_rename_telemetry "garbage" "junk" "api_error" + assert_contains "coerced to false" '"renamed":false' "${_TELEM_EVENTS[0]}" + assert_contains "coerced to false" '"allowed":false' "${_TELEM_EVENTS[0]}" +}; test_emit_coerces_invalid_booleans + +# ============================================================================ +echo "" +echo "── maybe_rename_account ──" +# ============================================================================ + +test_disabled_flag() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=true + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "info message" "Account rename disabled" "$_OUTPUT" + assert_contains "telemetry skipped_reason" '"disabled_flag"' "${_TELEM_EVENTS[0]}" +}; test_disabled_flag + +test_cli_missing() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + # Override aws to fail on 'account help' + aws() { + case "$1 $2" in + "account help") return 1 ;; + *) return 0 ;; + esac + } + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "info message" "AWS CLI v2.8+" "$_OUTPUT" + assert_contains "telemetry skipped_reason" '"cli_missing"' "${_TELEM_EVENTS[0]}" +}; test_cli_missing + +test_api_error_on_get() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + _AWS_ACCOUNT_INFO_EXIT=1 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "warn message" "Could not read account name" "$_OUTPUT" + assert_contains "telemetry skipped_reason" '"api_error"' "${_TELEM_EVENTS[0]}" +}; test_api_error_on_get + +test_already_prefixed() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"Loki-MyAccount"}' + _AWS_SSM_GET_EXIT=1 # SSM param not found → write it + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "ok message" "already named for Loki" "$_OUTPUT" + assert_contains "telemetry skipped_reason" '"already_prefixed"' "${_TELEM_EVENTS[0]}" +}; test_already_prefixed + +test_already_prefixed_case_insensitive() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"lOkI-MyAccount"}' + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "detects case-insensitive prefix" "already named for Loki" "$_OUTPUT" +}; test_already_prefixed_case_insensitive + +test_headless_no_opt_in() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=false + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"dev-account"}' + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "info message" "account rename skipped" "$_OUTPUT" + assert_contains "telemetry skipped_reason" '"headless_no_opt_in"' "${_TELEM_EVENTS[0]}" +}; test_headless_no_opt_in + +test_headless_auto_rename() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=true + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"dev-account"}' + _AWS_ACCOUNT_PUT_EXIT=0 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "ok message" "Account renamed" "$_OUTPUT" + assert_contains "telemetry renamed=true" '"renamed":true' "${_TELEM_EVENTS[0]}" + assert_contains "telemetry allowed=true" '"allowed":true' "${_TELEM_EVENTS[0]}" +}; test_headless_auto_rename + +test_headless_auto_rename_api_failure() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=true + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"dev-account"}' + _AWS_ACCOUNT_PUT_EXIT=1 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "warn message" "Could not rename account" "$_OUTPUT" + assert_contains "telemetry renamed=false" '"renamed":false' "${_TELEM_EVENTS[0]}" + assert_contains "telemetry allowed=true" '"allowed":true' "${_TELEM_EVENTS[0]}" + assert_contains "telemetry api_error" '"api_error"' "${_TELEM_EVENTS[0]}" +}; test_headless_auto_rename_api_failure + +test_empty_account_name_fallback() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=true + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":""}' + _AWS_ACCOUNT_PUT_EXIT=0 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "uses account ID fallback" "Loki-123456789012" "$_OUTPUT" +}; test_empty_account_name_fallback + +test_long_name_truncation() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=true + _AWS_ACCOUNT_INFO_EXIT=0 + # 50 char name + "Loki-" = 55, must truncate + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"ThisIsAVeryLongAccountNameThatExceedsFiftyCharsX"}' + _AWS_ACCOUNT_PUT_EXIT=0 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + assert_contains "truncation warning" "truncated to 50" "$_OUTPUT" + assert_contains "rename succeeds" "Account renamed" "$_OUTPUT" +}; test_long_name_truncation + +test_sanitize_then_prefix() { + source "$TMPDIR/functions.sh" + DISABLE_ACCOUNT_RENAME=false + AUTO_YES=true + AUTO_RENAME_ACCOUNT=true + _AWS_ACCOUNT_INFO_EXIT=0 + _AWS_ACCOUNT_INFO_RESULT='{"AccountName":"my$bad&name"}' + _AWS_ACCOUNT_PUT_EXIT=0 + _OUTPUT="" _TELEM_EVENTS=() + maybe_rename_account + # Should sanitize to "mybadname" then prefix → "Loki-mybadname" + assert_contains "sanitized and prefixed" "Loki-mybadname" "$_OUTPUT" +}; test_sanitize_then_prefix + +# ============================================================================ +echo "" +echo "── Flag parsing ──" +# ============================================================================ + +test_flag_auto_rename() { + local result + result=$(grep -c "\-\-auto-rename-account-enabled)" "$INSTALL_SH") + assert_eq "--auto-rename-account-enabled in case block" "1" "$result" +}; test_flag_auto_rename + +test_flag_disable_rename() { + local result + result=$(grep -c "\-\-disable-account-rename)" "$INSTALL_SH") + assert_eq "--disable-account-rename in case block" "1" "$result" +}; test_flag_disable_rename + +test_flag_defaults() { + assert_contains "AUTO_RENAME_ACCOUNT default" "AUTO_RENAME_ACCOUNT=false" "$(cat "$INSTALL_SH")" + assert_contains "DISABLE_ACCOUNT_RENAME default" "DISABLE_ACCOUNT_RENAME=false" "$(cat "$INSTALL_SH")" +}; test_flag_defaults + +# ============================================================================ +echo "" +echo "── main() call site ──" +# ============================================================================ + +test_main_calls_maybe_rename() { + assert_contains "maybe_rename_account called in main()" "maybe_rename_account" "$(sed -n '/^main()/,/^}/p' "$INSTALL_SH")" +}; test_main_calls_maybe_rename + +test_main_rename_before_console_deploy() { + # Verify maybe_rename_account is called BEFORE the console deploy early-exit + local main_body + main_body=$(sed -n '/^main()/,/^}/p' "$INSTALL_SH") + local rename_line console_line + rename_line=$(echo "$main_body" | grep -n "maybe_rename_account" | head -1 | cut -d: -f1) + console_line=$(echo "$main_body" | grep -n "DEPLOY_CFN_CONSOLE" | head -1 | cut -d: -f1) + if [[ -n "$rename_line" && -n "$console_line" && "$rename_line" -lt "$console_line" ]]; then + echo " ✓ maybe_rename_account before console deploy"; PASS=$((PASS + 1)) + else + echo " ✗ maybe_rename_account should be before console deploy" + echo " rename_line=$rename_line console_line=$console_line" + FAIL=$((FAIL + 1)) + fi +}; test_main_rename_before_console_deploy + +test_main_rename_guarded() { + # Verify the call is guarded with 2>/dev/null || true + assert_contains "guarded call" "maybe_rename_account 2>/dev/null || true" "$(cat "$INSTALL_SH")" +}; test_main_rename_guarded + +# ============================================================================ +echo "" +echo "── Beacon: account_rename_enabled ──" +# ============================================================================ + +test_beacon_includes_field() { + assert_contains "beacon includes account_rename_enabled" "account_rename_enabled" \ + "$(sed -n '/_telem_send_install_beacon/,/^}/p' "$INSTALL_SH")" +}; test_beacon_includes_field + +# ============================================================================ +echo "" +echo "── Help text ──" +# ============================================================================ + +test_help_auto_rename() { + assert_contains "help mentions --auto-rename-account-enabled" "auto-rename-account-enabled" "$(cat "$INSTALL_SH")" +}; test_help_auto_rename + +test_help_disable_rename() { + assert_contains "help mentions --disable-account-rename" "disable-account-rename" "$(cat "$INSTALL_SH")" +}; test_help_disable_rename + +# ============================================================================ +# Summary +# ============================================================================ +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Tests: $((PASS + FAIL)) Passed: $PASS Failed: $FAIL" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +[[ $FAIL -eq 0 ]] || exit 1