From 7d0b7718da8492b3ee092401f24ff68e889c1f95 Mon Sep 17 00:00:00 2001 From: badra001 Date: Wed, 4 Mar 2026 08:32:57 -0500 Subject: [PATCH] initial --- .../aws_config_editor/aws_config_editor.py | 610 ++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100755 local-app/python-tools/aws_config_editor/aws_config_editor.py diff --git a/local-app/python-tools/aws_config_editor/aws_config_editor.py b/local-app/python-tools/aws_config_editor/aws_config_editor.py new file mode 100755 index 00000000..67b8696d --- /dev/null +++ b/local-app/python-tools/aws_config_editor/aws_config_editor.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +""" +aws_config_editor.py +-------------------- +Bulk editor for AWS CLI config / credentials files. + +Features +-------- + list – List profiles, optionally filtered by regex/substring. + Pass --show-keys to dump each profile's key=value pairs. + delete – Delete profiles whose names match a pattern. + edit – Sed-style find/replace on key *values* inside matching profiles. + create – Create a new profile from inline key=value pairs, an existing + profile clone, or a Jinja2 template file. + +Comment & whitespace preservation +---------------------------------- +The file is parsed line-by-line so every comment (#/;) and blank line is kept +exactly where it was. configparser is only used for reading values; all writes +go through the line-level representation. + +Jinja2 is an optional dependency (only needed for --template / Jinja2 names): + pip install jinja2 + +Usage examples +-------------- + # List all profiles + python aws_config_editor.py list + + # List profiles matching a pattern, showing their keys + python aws_config_editor.py list --pattern "prod-.*" --show-keys + + # Delete stale temp profiles (dry-run first, then for real) + python aws_config_editor.py delete --pattern "temp-.*" --dry-run + python aws_config_editor.py delete --pattern "temp-.*" --yes + + # Replace source_profile value inside all govcloud profiles + python aws_config_editor.py edit --pattern "govcloud-.*" \\ + --key source_profile --search "old-base" --replace "new-base" + + # Regex substitution on role_session_name (back-reference) + python aws_config_editor.py edit --pattern ".*" \\ + --key role_session_name --search "^user_(.+)" --replace "svc_\\1" --regex + + # Create a profile from inline fields + python aws_config_editor.py create --name "new-account-dev" \\ + --set "region=us-east-1" --set "output=json" \\ + --set "role_arn=arn:aws:iam::111122223333:role/DevRole" \\ + --set "source_profile=base" + + # Clone an existing profile then override specific keys + python aws_config_editor.py create --name "new-account-prod" \\ + --source "existing-prod-template" \\ + --set "role_arn=arn:aws:iam::444455556666:role/ProdRole" + + # Create from a Jinja2 template file (pip install jinja2) + python aws_config_editor.py create --name "acct-{{ account_id }}-admin" \\ + --template profiles/govcloud_role.j2 \\ + --var "account_id=777788889999" --var "role=AdminRole" + + # Operate on credentials file instead of config + python aws_config_editor.py --file ~/.aws/credentials list +""" + +from __future__ import annotations + +import argparse +import re +import shutil +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path + + +# ── Defaults ────────────────────────────────────────────────────────────────── + +DEFAULT_CONFIG = Path.home() / ".aws" / "config" + + +# ── Line-level data model ───────────────────────────────────────────────────── + +@dataclass +class RawLine: + """One physical line from the file (trailing newline stripped).""" + text: str + + +@dataclass +class Profile: + """ + One INI section together with every raw line that belongs to it + (section header, key=value lines, comments, blank lines). + lines[0] is always the section header. + """ + name: str # logical name — no 'profile ' prefix + section_header: str # raw header text, e.g. '[profile foo]' + lines: list[RawLine] = field(default_factory=list) + + # ── key access ──────────────────────────────────────────────────────────── + + def keys(self) -> dict[str, str]: + """Return {key: value} for all key=value lines (skips comments/blanks).""" + out: dict[str, str] = {} + for rl in self.lines[1:]: + m = re.match(r"^(\s*)([A-Za-z0-9_]+)\s*=\s*(.*)", rl.text) + if m: + out[m.group(2)] = m.group(3).strip() + return out + + def set_key(self, key: str, value: str) -> None: + """Update an existing key in-place, or append it at the end.""" + for i, rl in enumerate(self.lines[1:], 1): + m = re.match(r"^(\s*)([A-Za-z0-9_]+)\s*=\s*(.*)", rl.text) + if m and m.group(2) == key: + indent = m.group(1) + self.lines[i] = RawLine(f"{indent}{key} = {value}") + return + self.lines.append(RawLine(f"{key} = {value}")) + + def sub_key(self, key: str, search: str, replace: str, regex: bool) -> bool: + """ + Perform search/replace on the value of *key*. + Returns True if a substitution was actually made. + """ + for i, rl in enumerate(self.lines[1:], 1): + m = re.match(r"^(\s*)([A-Za-z0-9_]+)\s*=\s*(.*)", rl.text) + if m and m.group(2) == key: + old_val = m.group(3).strip() + new_val = re.sub(search, replace, old_val) if regex else old_val.replace(search, replace) + if new_val != old_val: + self.lines[i] = RawLine(f"{m.group(1)}{key} = {new_val}") + return True + return False + return False + + def clone(self, new_name: str, is_config: bool) -> "Profile": + """Return a deep copy with a new section header.""" + new_header = _make_section_header(new_name, is_config) + new_lines = [RawLine(new_header)] + [RawLine(rl.text) for rl in self.lines[1:]] + return Profile(name=new_name, section_header=new_header, lines=new_lines) + + def render(self) -> str: + return "\n".join(rl.text for rl in self.lines) + + +# ── File-level model ────────────────────────────────────────────────────────── + +@dataclass +class AwsConfigFile: + """ + The entire file as: + preamble : lines that appear before the first section header + profiles : ordered list of Profile objects + """ + path: Path + is_config: bool # True → config file (uses 'profile ' prefix) + preamble: list[RawLine] = field(default_factory=list) + profiles: list[Profile] = field(default_factory=list) + + # ── loading ─────────────────────────────────────────────────────────────── + + @classmethod + def load(cls, path: Path, is_config: bool) -> "AwsConfigFile": + obj = cls(path=path, is_config=is_config) + if not path.exists(): + return obj + current: Profile | None = None + with open(path, encoding="utf-8") as fh: + for raw in fh: + text = raw.rstrip("\n\r") + rl = RawLine(text) + m = re.match(r"^\[(.+)\]", text.strip()) + if m: + section = m.group(1) + name = _profile_name(section, is_config) + current = Profile(name=name, section_header=text, lines=[rl]) + obj.profiles.append(current) + elif current is None: + obj.preamble.append(rl) + else: + current.lines.append(rl) + return obj + + # ── lookup ──────────────────────────────────────────────────────────────── + + def get(self, name: str) -> Profile | None: + for p in self.profiles: + if p.name == name: + return p + return None + + def matching(self, pattern: str, substring: bool) -> list[Profile]: + return [p for p in self.profiles if _matches(p.name, pattern, substring)] + + # ── mutation ────────────────────────────────────────────────────────────── + + def remove(self, name: str) -> bool: + before = len(self.profiles) + self.profiles = [p for p in self.profiles if p.name != name] + return len(self.profiles) < before + + def append_profile(self, profile: Profile) -> None: + self.profiles.append(profile) + + # ── persistence ─────────────────────────────────────────────────────────── + + def backup(self) -> Path: + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + dest = self.path.with_suffix(f".{ts}.bak") + shutil.copy2(self.path, dest) + return dest + + def write(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + parts: list[str] = [] + + if self.preamble: + parts.append("\n".join(rl.text for rl in self.preamble)) + + for prof in self.profiles: + if parts: + parts.append("") # blank separator between sections + parts.append(prof.render()) + + content = "\n".join(parts) + if not content.endswith("\n"): + content += "\n" + + with open(self.path, "w", encoding="utf-8") as fh: + fh.write(content) + + +# ── Pure helpers ────────────────────────────────────────────────────────────── + +def _profile_name(section: str, is_config: bool) -> str: + if is_config and section.startswith("profile "): + return section[len("profile "):] + return section + + +def _make_section_header(name: str, is_config: bool) -> str: + if is_config and name != "default": + return f"[profile {name}]" + return f"[{name}]" + + +def _matches(name: str, pattern: str, substring: bool) -> bool: + if substring: + return pattern in name + return bool(re.fullmatch(pattern, name)) + + +def _confirm(prompt: str, yes: bool) -> bool: + if yes: + return True + return input(prompt + " [y/N] ").strip().lower() in ("y", "yes") + + +def _open_file(args: argparse.Namespace) -> AwsConfigFile: + path = Path(args.file) + is_config = "credentials" not in path.name + return AwsConfigFile.load(path, is_config) + + +# ── Commands ────────────────────────────────────────────────────────────────── + +def cmd_list(args: argparse.Namespace) -> int: + """List profiles, optionally filtered by --pattern.""" + cf = _open_file(args) + profiles = cf.matching(args.pattern, args.substring) if args.pattern else cf.profiles + + if not profiles: + print("No matching profiles.") + return 0 + + for prof in profiles: + print(prof.name) + if args.show_keys: + for k, v in prof.keys().items(): + print(f" {k} = {v}") + + print(f"\n{len(profiles)} profile(s).") + return 0 + + +def cmd_delete(args: argparse.Namespace) -> int: + """Delete profiles matching --pattern.""" + cf = _open_file(args) + matched = cf.matching(args.pattern, args.substring) + + if not matched: + print(f"No profiles matched '{args.pattern}'.") + return 0 + + print(f"{len(matched)} profile(s) matched '{args.pattern}':") + for p in matched: + print(f" - {p.name}") + + if args.dry_run: + print("\nDRY RUN — no changes written.") + return 0 + + if not _confirm(f"\nDelete these {len(matched)} profile(s)?", args.yes): + print("Aborted.") + return 0 + + print(f"Backup: {cf.backup()}") + for p in matched: + cf.remove(p.name) + cf.write() + print(f"Removed {len(matched)} profile(s).") + return 0 + + +def cmd_edit(args: argparse.Namespace) -> int: + """ + Sed-style value substitution inside profiles matching --pattern. + + Examples + -------- + # Plain string: replace source_profile value in all govcloud profiles + edit --pattern "govcloud-.*" --key source_profile \\ + --search old-base --replace new-base + + # Regex with back-reference on role_session_name + edit --pattern ".*" --key role_session_name \\ + --search "^user_(.+)" --replace "svc_\\1" --regex + """ + cf = _open_file(args) + matched = cf.matching(args.pattern, args.substring) + + if not matched: + print(f"No profiles matched '{args.pattern}'.") + return 0 + + # Collect prospective changes (preview pass) + changes: list[tuple[str, str, str]] = [] # (profile_name, old_val, new_val) + for prof in matched: + kv = prof.keys() + if args.key not in kv: + continue + old_val = kv[args.key] + new_val = (re.sub(args.search, args.replace, old_val) + if args.regex + else old_val.replace(args.search, args.replace)) + if new_val != old_val: + changes.append((prof.name, old_val, new_val)) + + if not changes: + print(f"No values of '{args.key}' would change.") + return 0 + + print(f"{len(changes)} profile(s) would be updated:") + for name, old, new in changes: + print(f" {name}") + print(f" {args.key}: '{old}' → '{new}'") + + if args.dry_run: + print("\nDRY RUN — no changes written.") + return 0 + + if not _confirm(f"\nApply {len(changes)} change(s)?", args.yes): + print("Aborted.") + return 0 + + print(f"Backup: {cf.backup()}") + for name, _, _ in changes: + prof = cf.get(name) + if prof: + prof.sub_key(args.key, args.search, args.replace, args.regex) + cf.write() + print(f"Updated {len(changes)} profile(s).") + return 0 + + +def cmd_create(args: argparse.Namespace) -> int: + """ + Create a new profile. + + Source resolution order (highest precedence first): + 1. --template PATH — render a Jinja2 template for the profile body + 2. --source NAME — clone an existing profile as the starting point + 3. (none) — start from an empty profile + + --set KEY=VALUE is applied on top of any of the above. + + --name and --template vars may contain Jinja2 expressions when --var is + provided, e.g. --name "acct-{{ account_id }}-admin" + + Template format (keys only, no section header): + region = {{ region | default('us-east-1') }} + output = json + role_arn = arn:aws:iam::{{ account_id }}:role/{{ role }} + source_profile = {{ source_profile | default('base') }} + """ + cf = _open_file(args) + + # Parse --var / --set + template_vars: dict[str, str] = {} + for v in (args.var or []): + if "=" not in v: + print(f"[ERROR] --var must be KEY=VALUE, got: {v}", file=sys.stderr) + return 1 + k, val = v.split("=", 1) + template_vars[k.strip()] = val.strip() + + set_pairs: list[tuple[str, str]] = [] + for s in (args.set or []): + if "=" not in s: + print(f"[ERROR] --set must be KEY=VALUE, got: {s}", file=sys.stderr) + return 1 + k, val = s.split("=", 1) + set_pairs.append((k.strip(), val.strip())) + + profile_name = _render_jinja(args.name, template_vars) + + if cf.get(profile_name): + print(f"[ERROR] Profile '{profile_name}' already exists. " + "Use 'edit' to modify it.", file=sys.stderr) + return 1 + + # Build the Profile + if args.template: + new_prof = _profile_from_template( + args.template, profile_name, template_vars, cf.is_config + ) + if new_prof is None: + return 1 + elif args.source: + src = cf.get(args.source) + if src is None: + print(f"[ERROR] Source profile '{args.source}' not found.", file=sys.stderr) + return 1 + new_prof = src.clone(profile_name, cf.is_config) + else: + header = _make_section_header(profile_name, cf.is_config) + new_prof = Profile(name=profile_name, section_header=header, lines=[RawLine(header)]) + + for k, v in set_pairs: + new_prof.set_key(k, v) + + print(f"New profile '{profile_name}':") + print(new_prof.render()) + + if args.dry_run: + print("\nDRY RUN — no changes written.") + return 0 + + if not _confirm("\nAppend this profile?", args.yes): + print("Aborted.") + return 0 + + if cf.path.exists(): + print(f"Backup: {cf.backup()}") + + cf.append_profile(new_prof) + cf.write() + print(f"Profile '{profile_name}' written to {cf.path}.") + return 0 + + +# ── Jinja2 helpers (optional) ───────────────────────────────────────────────── + +def _render_jinja(text: str, variables: dict[str, str]) -> str: + if "{{" not in text: + return text + try: + from jinja2 import Template # type: ignore + return Template(text).render(**variables) + except ImportError: + print("[ERROR] jinja2 is required for Jinja2 expressions in --name. " + "Run: pip install jinja2", file=sys.stderr) + sys.exit(1) + + +def _profile_from_template( + template_path: str, + profile_name: str, + variables: dict[str, str], + is_config: bool, +) -> Profile | None: + try: + from jinja2 import Environment, FileSystemLoader, StrictUndefined # type: ignore + except ImportError: + print("[ERROR] jinja2 is required for --template. " + "Run: pip install jinja2", file=sys.stderr) + return None + + tpl_path = Path(template_path) + if not tpl_path.exists(): + print(f"[ERROR] Template file not found: {tpl_path}", file=sys.stderr) + return None + + env = Environment( + loader=FileSystemLoader(str(tpl_path.parent)), + undefined=StrictUndefined, + keep_trailing_newline=True, + ) + try: + rendered = env.get_template(tpl_path.name).render(**variables) + except Exception as exc: + print(f"[ERROR] Template rendering failed: {exc}", file=sys.stderr) + return None + + header = _make_section_header(profile_name, is_config) + lines = [RawLine(header)] + [RawLine(line) for line in rendered.splitlines()] + return Profile(name=profile_name, section_header=header, lines=lines) + + +# ── CLI construction ────────────────────────────────────────────────────────── + +def _add_pattern_args(p: argparse.ArgumentParser) -> None: + p.add_argument( + "--pattern", metavar="REGEX", + help="Profile name filter: full regex match (or substring with --substring)", + ) + p.add_argument( + "--substring", action="store_true", + help="Treat --pattern as a plain substring instead of a regex", + ) + + +def _add_mutation_args(p: argparse.ArgumentParser) -> None: + p.add_argument("--dry-run", action="store_true", + help="Preview changes without writing anything") + p.add_argument("--yes", "-y", action="store_true", + help="Skip confirmation prompt") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Bulk editor for AWS CLI config / credentials files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "--file", "-f", + default=str(DEFAULT_CONFIG), + metavar="PATH", + help=f"Config or credentials file to operate on (default: {DEFAULT_CONFIG})", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + # list + lp = sub.add_parser("list", help="List profiles, optionally filtered") + _add_pattern_args(lp) + lp.add_argument("--show-keys", action="store_true", + help="Print each profile's key=value pairs below its name") + lp.set_defaults(func=cmd_list) + + # delete + dp = sub.add_parser("delete", help="Delete profiles matching a pattern") + _add_pattern_args(dp) + dp.add_argument("--pattern", required=True, metavar="REGEX") + _add_mutation_args(dp) + dp.set_defaults(func=cmd_delete) + + # edit + ep = sub.add_parser( + "edit", + help="Sed-style find/replace on a key value inside matching profiles", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=cmd_edit.__doc__, + ) + _add_pattern_args(ep) + ep.add_argument("--pattern", required=True, metavar="REGEX") + ep.add_argument("--key", required=True, metavar="KEY", + help="Config key whose value will be modified (e.g. source_profile)") + ep.add_argument("--search", required=True, metavar="SEARCH", + help="Text (or regex with --regex) to find in the key's value") + ep.add_argument("--replace", required=True, metavar="REPLACE", + help="Replacement text (supports \\1 back-refs when --regex is set)") + ep.add_argument("--regex", action="store_true", + help="Treat --search as a Python regex (re.sub semantics)") + _add_mutation_args(ep) + ep.set_defaults(func=cmd_edit) + + # create + cp = sub.add_parser( + "create", + help="Create a new profile (from inline values, a clone, or a Jinja2 template)", + formatter_class=argparse.RawDescriptionHelpFormatter, + description=cmd_create.__doc__, + ) + cp.add_argument("--name", required=True, metavar="NAME", + help="New profile name (may contain Jinja2 {{ }} with --var)") + cp.add_argument("--source", metavar="PROFILE", + help="Existing profile to clone as the base") + cp.add_argument("--template", metavar="PATH", + help="Jinja2 template file (keys only, no section header)") + cp.add_argument("--set", dest="set", action="append", metavar="KEY=VALUE", + help="Set/override a key. Repeatable.") + cp.add_argument("--var", action="append", metavar="KEY=VALUE", + help="Variable for Jinja2 template or --name expression. Repeatable.") + _add_mutation_args(cp) + cp.set_defaults(func=cmd_create) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main())