
Aws Skill
- 11 installs
- 12 repo stars
- Updated August 4, 2026
- idanbeck/claude-skills
Opinionated boto3 CLI over AWS for reads, writes, and intent commands across IAM, EC2, S3, RDS, Lambda, and more with customer-tagged multi-account auth.
About
A CLI wrapper over AWS that encodes customer-tagged resources, safe defaults, and intent-level commands across many services plus cost reports and security audits. A developer uses it for routine AWS ops instead of the console.
- Read/write/intent ops across IAM, EC2, S3, RDS, Lambda, VPC, and more
- Jumphost provisioning, security audit, per-customer cost reports, Terraform
Aws Skill by the numbers
- 11 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #841 of 1,039 Cloud & Infrastructure skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/idanbeck/claude-skills --skill aws-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 11 |
|---|---|
| repo stars | ★ 12 |
| Last updated | August 4, 2026 |
| Repository | idanbeck/claude-skills ↗ |
What it does
Opinionated boto3 CLI over AWS for reads, writes, and intent commands across IAM, EC2, S3, RDS, Lambda, and more with customer-tagged multi-account auth.
Files
AWS Skill
A CLI wrapper over AWS that encodes our patterns: customer-tagged resources, safe defaults, multi-account auth, intent-level commands. Use it instead of the AWS console for routine ops.
When to use this skill
- Any AWS read (ec2, s3, iam, rds, lambda, vpc, route53, cloudwatch, ecr, eks)
- Cost lookups (last-30d, by-service, by-tag, per-customer report)
- Provisioning a static-IP jump host for a customer engagement (
jumphost) - Generating a Terraform module from an intent op (
terraform jumphost) - Running a security audit (open SGs, public S3, old IAM keys, no-MFA users, public RDS)
- Hunting untagged resources (
cleanup untagged) - Cross-service inventory by customer or tag
- First-time AWS setup on this machine (
setupsubcommand)
Do NOT use for:
- IAM writes (do those in console / Terraform — too easy to break things)
- Bulk S3 sync (use
aws s3 syncdirectly — built for it) - Cluster creation / deletion (Terraform / eksctl — declarative beats imperative)
First-time setup
python3 ~/.claude/skills/aws-skill/aws_skill.py setupWalks through aws configure --profile epoch and validates with STS. The skill defaults to the epoch profile; --profile NAME overrides.
pip install -r ~/.claude/skills/aws-skill/requirements.txtUniversal flags
--profile NAME AWS profile (default: epoch, or AWS_PROFILE env)
--region REGION override profile region
--format FMT json (default), table, markdown, ids
--confirm required for write operations
--confirm-delete required for destructive operations (separate flag)
--dry-run preview, don't executeReads are free. Writes require --confirm. Deletes require --confirm-delete — intentionally a different flag from --confirm to prevent muscle-memory mistakes.
Service commands
IAM (read-only)
aws_skill.py iam who-am-i # STS GetCallerIdentity + account alias
aws_skill.py iam list-users
aws_skill.py iam list-roles
aws_skill.py iam list-policies # customer-managed by default
aws_skill.py iam list-policies --scope AWSEC2
# reads
aws_skill.py ec2 list # running + pending only
aws_skill.py ec2 list --all # include stopped/terminated
aws_skill.py ec2 list --customer dmatrix
aws_skill.py ec2 describe i-0abcd1234
aws_skill.py ec2 addresses --customer dmatrix # Elastic IPs
# writes (--confirm)
aws_skill.py ec2 start i-0abcd1234 --confirm
aws_skill.py ec2 stop i-0abcd1234 --confirm
aws_skill.py ec2 alloc-eip --confirm
aws_skill.py ec2 associate-eip --allocation-id eipalloc-xxx --instance-id i-xxx --confirm
# deletes (--confirm-delete)
aws_skill.py ec2 terminate i-0abcd1234 --confirm-delete
aws_skill.py ec2 release-eip eipalloc-xxx --confirm-deleteS3
aws_skill.py s3 ls-buckets
aws_skill.py s3 ls my-bucket [--prefix path/]
aws_skill.py s3 head my-bucket some/key
aws_skill.py s3 get my-bucket some/key --out ~/Downloads/file
aws_skill.py s3 put my-bucket some/key ~/local/path --confirm
aws_skill.py s3 rm my-bucket some/key --confirm-delete
aws_skill.py s3 public-status my-bucket # exposure assessmentRDS
aws_skill.py rds list [--customer NAME]
aws_skill.py rds describe my-db
aws_skill.py rds snapshot my-db --confirm
aws_skill.py rds list-snapshots [--instance-id my-db]Lambda
aws_skill.py lambda list [--customer NAME]
aws_skill.py lambda get my-function
aws_skill.py lambda invoke my-function --payload '{"x":1}' --confirm
aws_skill.py lambda invoke my-function --payload @./payload.json --confirm
aws_skill.py lambda logs my-function --since 30m --limit 100VPC
aws_skill.py vpc list
aws_skill.py vpc subnets [--vpc-id vpc-xxx]
aws_skill.py vpc route-tables [--vpc-id vpc-xxx]
aws_skill.py vpc nat [--vpc-id vpc-xxx]Route 53
aws_skill.py route53 zones
aws_skill.py route53 records --zone-id ZONE_ID
aws_skill.py route53 upsert --zone-id ZONE --name foo.example. --type A --value 1.2.3.4 --confirm
aws_skill.py route53 delete --zone-id ZONE --name foo.example. --type A --value 1.2.3.4 --confirm-deleteCloudWatch
aws_skill.py cloudwatch log-groups [--prefix /aws/lambda/]
aws_skill.py cloudwatch logs /aws/lambda/my-fn --since 1h --limit 500 [--filter "ERROR"]
aws_skill.py cloudwatch metric --namespace AWS/EC2 --name CPUUtilization --days 1ECR
aws_skill.py ecr list # repositories
aws_skill.py ecr images my-repo --limit 50
aws_skill.py ecr login # docker login command + tokenEKS
aws_skill.py eks list [--customer NAME]
aws_skill.py eks kubeconfig my-cluster --confirm # writes ~/.kube/configCost Explorer
aws_skill.py cost last-30d # total spend
aws_skill.py cost by-service # grouped by AWS service
aws_skill.py cost by-tag --key Customer # grouped by Customer tag
aws_skill.py cost report --customer dmatrix --days 30 # detailed per-customer report (intent)Cost-allocation tag note:by-tagandreport --customerrequire theCustomertag to be activated as a cost-allocation tag in the AWS Billing console. Otherwise totals come back zero.
Intent commands
Jumphost (provision / teardown)
aws_skill.py jumphost provision \
--customer dmatrix \
--allowed-ip 1.2.3.4/32 \
--confirm
aws_skill.py jumphost teardown --customer dmatrix --confirm-deleteCreates: SSH key pair (saved locally), security group with port-22 ingress restricted to --allowed-ip, EC2 instance (Ubuntu 22.04 LTS, t4g.small default), Elastic IP, and association. Everything is tagged with Customer, Project=jumphost, Owner, Environment, ManagedBy=zerg-aws-skill.
Customer-specific config (region, allowed-ingress, instance type, key path) lives at customers/<name>.json. Copy templates/customer-config.example.json to start a new customer.
Inventory
aws_skill.py inventory --customer dmatrix # all skill-touchable resources for a customer
aws_skill.py inventory --tag-key Project --tag-value jumphostCleanup (untagged-resource hunter)
# Report only — never deletes
aws_skill.py cleanup untagged
# Auto-delete safe categories (unattached EIPs, stopped instances older than 7 days)
aws_skill.py cleanup auto --confirm-delete --older-than-days 7
# Preview what auto would do
aws_skill.py cleanup auto --dry-runSecurity audit
aws_skill.py audit # all checks
aws_skill.py audit --key-age-days 60 --format tableChecks:
- Security groups with
0.0.0.0/0ingress on non-web ports (SSH = high; web = info) - S3 buckets with public ACL grants or missing public-access-block
- IAM access keys older than
--key-age-days(default 90) - IAM users with passwords but no MFA
- RDS instances with
PubliclyAccessible = true - Resources missing required tags (delegates to
cleanup untagged)
Output groups by severity (high/medium/low/info) with a recommendation per finding.
Per-customer cost report
aws_skill.py cost report --customer dmatrix --days 30Returns total + per-AWS-service breakdown + last-6-months trend, all filtered to the Customer tag.
Terraform integration
Render an intent op as a stand-alone Terraform module instead of executing it via boto3:
aws_skill.py terraform jumphost --customer dmatrixWrites terraform/<customer>/{main.tf,variables.tf,outputs.tf,user-data.sh}. Then:
cd ~/.claude/skills/aws-skill/terraform/dmatrix
terraform init && terraform plan && terraform applySame intent — different execution path. Use boto3 path for fast, scriptable provisioning; use Terraform path for declarative, drift-aware infrastructure with shared state.
The jumphost terraform --customer NAME shortcut is identical:
aws_skill.py jumphost terraform --customer dmatrixOutput formats
json(default) — pretty JSON. What Claude expects.table— psql-style terminal table.markdown— github-flavored markdown table (good for vault notes).ids— one resource ID per line. Composable:aws_skill.py ec2 list --format ids | xargs ...
Required-tag policy
Every skill-managed resource carries:
| Tag | Example | Purpose |
|---|---|---|
| Customer | dmatrix | cost allocation, cleanup, inventory |
| Project | jumphost, poc | sub-allocation within customer |
| Owner | idan@zergai.com | who to ask |
| Environment | dev/staging/prod | lifecycle |
| ManagedBy | zerg-aws-skill | distinguish skill-managed from manual |
Account model
v1+v2 use a single Epoch AWS account with customer-tagged resources. The --profile flag is AWS-native — switching to per-customer accounts later is a config swap, not a code change.
Safety
- Reads are free.
- Writes require
--confirm. - Deletes require
--confirm-delete. --dry-runpreviews any operation.- Skill never stores credentials itself; honors
~/.aws/credentialsand~/.aws/config. cleanup autoonly acts on categorically-safe types (unattached EIPs, stopped EC2 older than threshold). Security groups, EBS volumes, key pairs are reported only.
Errors
Errors are emitted as {"error": "..."} JSON with non-zero exit code:
| Code | Meaning |
|---|---|
| 2 | Auth failure (profile missing, SSO expired, etc.) |
| 3 | Confirmation required (re-run with --confirm) |
| 4 | Local file conflict (e.g. SSH key already exists) |
| 5 | Bad arguments (missing customer config, etc.) |
| 10 | AWS API error or unexpected exception |
Common workflows
Stand up a customer jump host
python3 ~/.claude/skills/aws-skill/aws_skill.py iam who-am-i
python3 ~/.claude/skills/aws-skill/aws_skill.py jumphost provision --customer dmatrix --dry-run
python3 ~/.claude/skills/aws-skill/aws_skill.py jumphost provision --customer dmatrix --confirm
# (SSH using the printed hint)
python3 ~/.claude/skills/aws-skill/aws_skill.py jumphost teardown --customer dmatrix --confirm-deleteCost by customer
python3 ~/.claude/skills/aws-skill/aws_skill.py cost by-tag --key Customer --format table
python3 ~/.claude/skills/aws-skill/aws_skill.py cost report --customer dmatrix --days 30Find everything tagged for a customer
python3 ~/.claude/skills/aws-skill/aws_skill.py inventory --customer dmatrixRun a security audit before an external review
python3 ~/.claude/skills/aws-skill/aws_skill.py audit --format tableClean up forgotten EIPs and stopped instances
python3 ~/.claude/skills/aws-skill/aws_skill.py cleanup untagged # see the list
python3 ~/.claude/skills/aws-skill/aws_skill.py cleanup auto --dry-run # preview action
python3 ~/.claude/skills/aws-skill/aws_skill.py cleanup auto --confirm-deleteHand a jumphost to Terraform instead of executing via boto3
python3 ~/.claude/skills/aws-skill/aws_skill.py terraform jumphost --customer dmatrix
cd ~/.claude/skills/aws-skill/terraform/dmatrix
terraform init && terraform plan && terraform apply# Per-customer config (may contain network-sensitive details)
customers/*.json
!customers/.gitkeep
# Generated Terraform configurations (per-customer; may contain CIDRs)
terraform/
# AWS-shaped secrets that should never reach git
credentials.json
config.json
*.pem
*.key
.env
.env.*
# Local Python detritus
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
# OS
.DS_Store
#!/usr/bin/env python3
"""aws-skill: a customer-tagged, opinionated CLI over boto3.
Conventions:
- Reads are free.
- Writes require --confirm.
- Deletes require --confirm-delete (separate flag, intentional).
- Default profile is `epoch` (overridable via --profile or AWS_PROFILE env).
- Default output is JSON; --format table|markdown|ids overrides.
Run `python3 aws_skill.py setup` first if `~/.aws/` isn't configured yet.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
# Allow running as script (`python3 aws_skill.py ...`) without install.
import os as _os
import sys as _sys
_SKILL_DIR = _os.path.dirname(_os.path.abspath(__file__))
if _SKILL_DIR not in _sys.path:
_sys.path.insert(0, _SKILL_DIR)
from lib import auth # noqa: E402
from lib.confirm import ( # noqa: E402
ConfirmationRequired,
is_dry_run,
require_confirm,
require_confirm_delete,
)
from lib.output import emit, emit_error # noqa: E402
from lib.services import cloudwatch as cloudwatch_svc # noqa: E402
from lib.services import cost as cost_svc # noqa: E402
from lib.services import ec2 as ec2_svc # noqa: E402
from lib.services import ecr as ecr_svc # noqa: E402
from lib.services import eks as eks_svc # noqa: E402
from lib.services import iam as iam_svc # noqa: E402
from lib.services import lambda_ops as lambda_svc # noqa: E402
from lib.services import rds as rds_svc # noqa: E402
from lib.services import route53 as route53_svc # noqa: E402
from lib.services import s3 as s3_svc # noqa: E402
from lib.services import vpc as vpc_svc # noqa: E402
from lib.intent import audit as audit_intent # noqa: E402
from lib.intent import bootstrap as bootstrap_intent # noqa: E402
from lib.intent import cleanup as cleanup_intent # noqa: E402
from lib.intent import cost_report as cost_report_intent # noqa: E402
from lib.intent import inventory as inventory_intent # noqa: E402
from lib.intent import jumphost as jumphost_intent # noqa: E402
from lib.intent import terraform as terraform_intent # noqa: E402
# ---- Top-level argparse ----------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="aws-skill",
description="Customer-tagged, opinionated CLI over boto3.",
)
p.add_argument("--profile", default=None,
help="AWS profile name (default: $AWS_PROFILE or 'epoch').")
p.add_argument("--region", default=None,
help="AWS region (default: profile region).")
p.add_argument("--format", default="json",
choices=["json", "table", "markdown", "ids"],
help="Output format (default: json).")
p.add_argument("--confirm", action="store_true",
help="Required for write operations.")
p.add_argument("--confirm-delete", action="store_true", dest="confirm_delete",
help="Required for destructive operations.")
p.add_argument("--dry-run", action="store_true", dest="dry_run",
help="Preview the operation without executing.")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("setup", help="Initialize ~/.aws/ profile interactively.")
_build_iam(sub)
_build_ec2(sub)
_build_s3(sub)
_build_rds(sub)
_build_lambda(sub)
_build_vpc(sub)
_build_route53(sub)
_build_cloudwatch(sub)
_build_ecr(sub)
_build_eks(sub)
_build_cost(sub)
_build_jumphost(sub)
_build_inventory(sub)
_build_cleanup(sub)
_build_audit(sub)
_build_terraform(sub)
_build_bootstrap(sub)
return p
# ---- Subparser builders (one per service / intent) -------------------------
def _build_iam(sub) -> None:
iam = sub.add_parser("iam", help="IAM read operations.").add_subparsers(
dest="iam_op", required=True
)
iam.add_parser("who-am-i", help="STS GetCallerIdentity.")
iam.add_parser("list-users", help="List IAM users.")
iam.add_parser("list-roles", help="List IAM roles.")
p_pol = iam.add_parser("list-policies", help="List managed policies.")
p_pol.add_argument("--scope", default="Local", choices=["Local", "AWS", "All"])
def _build_ec2(sub) -> None:
ec2 = sub.add_parser("ec2", help="EC2 operations.").add_subparsers(
dest="ec2_op", required=True
)
p_list = ec2.add_parser("list", help="List EC2 instances.")
p_list.add_argument("--customer", default=None)
p_list.add_argument("--project", default=None)
p_list.add_argument("--all", action="store_true", dest="show_all",
help="Include stopped/terminated instances.")
p_desc = ec2.add_parser("describe", help="Describe a single instance.")
p_desc.add_argument("instance_id")
for op in ("start", "stop", "terminate"):
p_op = ec2.add_parser(op, help=f"{op} an instance.")
p_op.add_argument("instance_id")
p_resize = ec2.add_parser("resize", help="Change instance type (stop, modify, restart).")
p_resize.add_argument("instance_id")
p_resize.add_argument("--instance-type", required=True, dest="instance_type")
p_alloc = ec2.add_parser("alloc-eip", help="Allocate an Elastic IP.")
p_alloc.add_argument("--customer", default=None)
p_alloc.add_argument("--project", default=None)
p_assoc = ec2.add_parser("associate-eip", help="Associate EIP with instance.")
p_assoc.add_argument("--allocation-id", required=True, dest="allocation_id")
p_assoc.add_argument("--instance-id", required=True, dest="instance_id")
p_release = ec2.add_parser("release-eip", help="Release an Elastic IP.")
p_release.add_argument("allocation_id")
p_addrs = ec2.add_parser("addresses", help="List Elastic IPs.")
p_addrs.add_argument("--customer", default=None)
p_addrs.add_argument("--project", default=None)
def _build_s3(sub) -> None:
s3 = sub.add_parser("s3", help="S3 operations.").add_subparsers(
dest="s3_op", required=True
)
s3.add_parser("ls-buckets", help="List buckets.")
p_ls = s3.add_parser("ls", help="List objects in a bucket.")
p_ls.add_argument("bucket")
p_ls.add_argument("--prefix", default=None)
p_ls.add_argument("--max", type=int, default=1000, dest="max_items")
p_head = s3.add_parser("head", help="Head object metadata.")
p_head.add_argument("bucket")
p_head.add_argument("key")
p_get = s3.add_parser("get", help="Download an object.")
p_get.add_argument("bucket")
p_get.add_argument("key")
p_get.add_argument("--out", required=True, dest="out_path")
p_put = s3.add_parser("put", help="Upload a file.")
p_put.add_argument("bucket")
p_put.add_argument("key")
p_put.add_argument("src_path")
p_rm = s3.add_parser("rm", help="Delete an object.")
p_rm.add_argument("bucket")
p_rm.add_argument("key")
p_pub = s3.add_parser("public-status", help="Assess bucket public-access exposure.")
p_pub.add_argument("bucket")
def _build_rds(sub) -> None:
rds = sub.add_parser("rds", help="RDS operations.").add_subparsers(
dest="rds_op", required=True
)
p_list = rds.add_parser("list", help="List RDS instances.")
p_list.add_argument("--customer", default=None)
p_desc = rds.add_parser("describe", help="Describe an RDS instance.")
p_desc.add_argument("instance_id")
p_snap = rds.add_parser("snapshot", help="Create a manual snapshot.")
p_snap.add_argument("instance_id")
p_snap.add_argument("--name", default=None, dest="snapshot_id")
p_snaps = rds.add_parser("list-snapshots", help="List snapshots.")
p_snaps.add_argument("--instance-id", default=None, dest="instance_id")
def _build_lambda(sub) -> None:
lam = sub.add_parser("lambda", help="Lambda operations.").add_subparsers(
dest="lambda_op", required=True
)
p_list = lam.add_parser("list", help="List functions.")
p_list.add_argument("--customer", default=None)
p_get = lam.add_parser("get", help="Describe a function.")
p_get.add_argument("name")
p_inv = lam.add_parser("invoke", help="Invoke a function.")
p_inv.add_argument("name")
p_inv.add_argument("--payload", default=None,
help="JSON payload (string or @path).")
p_inv.add_argument("--invocation-type", default="RequestResponse",
choices=["RequestResponse", "Event", "DryRun"],
dest="invocation_type")
p_logs = lam.add_parser("logs", help="Tail recent logs.")
p_logs.add_argument("name")
p_logs.add_argument("--since", default="1h")
p_logs.add_argument("--limit", type=int, default=200)
def _build_vpc(sub) -> None:
vpc = sub.add_parser("vpc", help="VPC operations.").add_subparsers(
dest="vpc_op", required=True
)
p_list = vpc.add_parser("list", help="List VPCs.")
p_list.add_argument("--customer", default=None)
p_subs = vpc.add_parser("subnets", help="List subnets.")
p_subs.add_argument("--vpc-id", default=None, dest="vpc_id")
p_subs.add_argument("--customer", default=None)
p_rt = vpc.add_parser("route-tables", help="List route tables.")
p_rt.add_argument("--vpc-id", default=None, dest="vpc_id")
p_nat = vpc.add_parser("nat", help="List NAT gateways.")
p_nat.add_argument("--vpc-id", default=None, dest="vpc_id")
def _build_route53(sub) -> None:
r53 = sub.add_parser("route53", help="Route 53 operations.").add_subparsers(
dest="r53_op", required=True
)
r53.add_parser("zones", help="List hosted zones.")
p_recs = r53.add_parser("records", help="List records in a zone.")
p_recs.add_argument("--zone-id", required=True, dest="zone_id")
p_up = r53.add_parser("upsert", help="Create or update a record.")
p_up.add_argument("--zone-id", required=True, dest="zone_id")
p_up.add_argument("--name", required=True)
p_up.add_argument("--type", required=True)
p_up.add_argument("--value", action="append", required=True, dest="values")
p_up.add_argument("--ttl", type=int, default=300)
p_del = r53.add_parser("delete", help="Delete a record.")
p_del.add_argument("--zone-id", required=True, dest="zone_id")
p_del.add_argument("--name", required=True)
p_del.add_argument("--type", required=True)
p_del.add_argument("--value", action="append", required=True, dest="values")
p_del.add_argument("--ttl", type=int, default=300)
def _build_cloudwatch(sub) -> None:
cw = sub.add_parser("cloudwatch", help="CloudWatch operations.").add_subparsers(
dest="cw_op", required=True
)
p_lg = cw.add_parser("log-groups", help="List log groups.")
p_lg.add_argument("--prefix", default=None, dest="name_prefix")
p_logs = cw.add_parser("logs", help="Tail a log group.")
p_logs.add_argument("log_group")
p_logs.add_argument("--since", default="1h")
p_logs.add_argument("--limit", type=int, default=500)
p_logs.add_argument("--filter", default=None, dest="filter_pattern")
p_metric = cw.add_parser("metric", help="Get metric statistics.")
p_metric.add_argument("--namespace", required=True)
p_metric.add_argument("--name", required=True, dest="metric_name")
p_metric.add_argument("--days", type=int, default=1)
p_metric.add_argument("--period", type=int, default=300, dest="period_seconds")
def _build_ecr(sub) -> None:
ecr = sub.add_parser("ecr", help="ECR operations.").add_subparsers(
dest="ecr_op", required=True
)
ecr.add_parser("list", help="List repositories.")
p_imgs = ecr.add_parser("images", help="List images in a repo.")
p_imgs.add_argument("repo")
p_imgs.add_argument("--limit", type=int, default=50)
ecr.add_parser("login", help="Get docker login command (12h token).")
def _build_eks(sub) -> None:
eks = sub.add_parser("eks", help="EKS operations.").add_subparsers(
dest="eks_op", required=True
)
p_list = eks.add_parser("list", help="List EKS clusters.")
p_list.add_argument("--customer", default=None)
p_kc = eks.add_parser("kubeconfig", help="Write kubeconfig for a cluster.")
p_kc.add_argument("cluster_name")
p_kc.add_argument("--out", default=None, dest="kubeconfig_path")
def _build_cost(sub) -> None:
cost = sub.add_parser("cost", help="Cost Explorer operations.").add_subparsers(
dest="cost_op", required=True
)
p_l30 = cost.add_parser("last-30d", help="Total cost over the last 30 days.")
p_l30.add_argument("--days", type=int, default=30)
p_bs = cost.add_parser("by-service", help="Cost grouped by AWS service.")
p_bs.add_argument("--days", type=int, default=30)
p_bt = cost.add_parser("by-tag", help="Cost grouped by tag value.")
p_bt.add_argument("--key", required=True, help="Tag key (e.g. Customer).")
p_bt.add_argument("--days", type=int, default=30)
p_rep = cost.add_parser("report", help="Per-customer cost report (intent).")
p_rep.add_argument("--customer", required=True)
p_rep.add_argument("--days", type=int, default=30)
def _build_jumphost(sub) -> None:
jh = sub.add_parser("jumphost",
help="Customer jump-host provision/teardown.").add_subparsers(
dest="jh_op", required=True
)
p_prov = jh.add_parser("provision", help="Provision a jump host.")
p_prov.add_argument("--customer", required=True)
p_prov.add_argument("--allowed-ip", action="append", default=None,
dest="allowed_ips",
help="CIDR to allow on port 22. Repeatable.")
p_prov.add_argument("--instance-type", default=None, dest="instance_type")
p_prov.add_argument("--environment", default="dev")
p_td = jh.add_parser("teardown", help="Tear down a jump host.")
p_td.add_argument("--customer", required=True)
p_tf = jh.add_parser("terraform",
help="Render the jumphost as Terraform instead of executing.")
p_tf.add_argument("--customer", required=True)
p_tf.add_argument("--allowed-ip", action="append", default=None,
dest="allowed_ips",
help="CIDR to allow on port 22. Repeatable.")
p_tf.add_argument("--instance-type", default=None, dest="instance_type")
p_tf.add_argument("--environment", default="dev")
p_tf.add_argument("--out-dir", default=None, dest="out_dir",
help="Override default terraform/<customer>/ directory.")
def _build_inventory(sub) -> None:
inv = sub.add_parser("inventory", help="Tag-based resource listing.")
inv.add_argument("--customer", default=None)
inv.add_argument("--tag-key", default=None, dest="tag_key")
inv.add_argument("--tag-value", default=None, dest="tag_value")
def _build_cleanup(sub) -> None:
cl = sub.add_parser("cleanup",
help="Find / remove untagged resources.").add_subparsers(
dest="cleanup_op", required=True
)
cl.add_parser("untagged",
help="Report resources missing required tags (read-only).")
p_auto = cl.add_parser("auto",
help="Auto-delete safe untagged resources (EIPs + old stopped EC2).")
p_auto.add_argument("--older-than-days", type=int, default=7,
dest="older_than_days")
def _build_audit(sub) -> None:
a = sub.add_parser("audit", help="Run security / hygiene audit.")
a.add_argument("--key-age-days", type=int, default=90, dest="key_age_days")
def _build_terraform(sub) -> None:
"""Standalone `terraform` subcommand (also reachable as `jumphost terraform`)."""
tf = sub.add_parser("terraform",
help="Render intent operations as Terraform.").add_subparsers(
dest="tf_op", required=True
)
p_jh = tf.add_parser("jumphost", help="Render a jumphost as .tf files.")
p_jh.add_argument("--customer", required=True)
p_jh.add_argument("--allowed-ip", action="append", default=None,
dest="allowed_ips",
help="CIDR to allow on port 22. Repeatable.")
p_jh.add_argument("--instance-type", default=None, dest="instance_type")
p_jh.add_argument("--environment", default="dev")
p_jh.add_argument("--out-dir", default=None, dest="out_dir")
def _build_bootstrap(sub) -> None:
bs = sub.add_parser(
"bootstrap",
help="One-time setup intents (e.g. create IAM admin off root)."
).add_subparsers(dest="bootstrap_op", required=True)
p_iam = bs.add_parser(
"iam-admin",
help="Create an IAM user with AdministratorAccess + access keys."
)
p_iam.add_argument("--username", required=True)
p_iam.add_argument("--update-profile", default=None, dest="update_profile",
help="Write the new keys into ~/.aws/credentials as this profile.")
p_iam.add_argument("--with-console-password", action="store_true",
dest="with_console_password",
help="Also create a console login password.")
# ---- Dispatcher ------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "setup":
return auth.setup_wizard()
# `terraform` only renders local files; doesn't need an AWS session.
if args.command == "terraform":
try:
result = _dispatch_terraform(args)
except ValueError as e:
emit_error(str(e), exit_code=5)
return 5
except Exception as e:
emit_error(f"{type(e).__name__}: {e}", exit_code=10)
return 10
emit(result, fmt=args.format)
return 0
try:
session = auth.get_session(profile=args.profile, region=args.region)
except auth.AuthError as e:
emit_error(str(e), exit_code=2)
return 2
try:
result = _dispatch(session, args)
except ConfirmationRequired as e:
emit_error(str(e), exit_code=3)
return 3
except FileExistsError as e:
emit_error(str(e), exit_code=4)
return 4
except ValueError as e:
emit_error(str(e), exit_code=5)
return 5
except Exception as e:
emit_error(f"{type(e).__name__}: {e}", exit_code=10)
return 10
columns = _columns_for(args)
id_key = _id_key_for(args)
emit(result, fmt=args.format, columns=columns, id_key=id_key)
return 0
def _dispatch(session, args) -> Any:
cmd = args.command
if cmd == "iam":
return _dispatch_iam(session, args)
if cmd == "ec2":
return _dispatch_ec2(session, args)
if cmd == "s3":
return _dispatch_s3(session, args)
if cmd == "rds":
return _dispatch_rds(session, args)
if cmd == "lambda":
return _dispatch_lambda(session, args)
if cmd == "vpc":
return _dispatch_vpc(session, args)
if cmd == "route53":
return _dispatch_route53(session, args)
if cmd == "cloudwatch":
return _dispatch_cloudwatch(session, args)
if cmd == "ecr":
return _dispatch_ecr(session, args)
if cmd == "eks":
return _dispatch_eks(session, args)
if cmd == "cost":
return _dispatch_cost(session, args)
if cmd == "jumphost":
return _dispatch_jumphost(session, args)
if cmd == "inventory":
return _dispatch_inventory(session, args)
if cmd == "cleanup":
return _dispatch_cleanup(session, args)
if cmd == "audit":
return audit_intent.run(session, key_age_days=args.key_age_days)
if cmd == "terraform":
return _dispatch_terraform(args)
if cmd == "bootstrap":
return _dispatch_bootstrap(session, args)
raise NotImplementedError(f"Unhandled command: {cmd}")
def _dispatch_iam(session, args):
op = args.iam_op
if op == "who-am-i":
return iam_svc.who_am_i(session)
if op == "list-users":
return iam_svc.list_users(session)
if op == "list-roles":
return iam_svc.list_roles(session)
if op == "list-policies":
return iam_svc.list_policies(session, scope=args.scope)
def _dispatch_ec2(session, args):
op = args.ec2_op
if op == "list":
return ec2_svc.list_instances(
session, customer=args.customer, project=args.project,
show_all=args.show_all,
)
if op == "describe":
return ec2_svc.describe_instance(session, args.instance_id)
if op == "addresses":
return ec2_svc.list_addresses(
session, customer=args.customer, project=args.project
)
if op == "start":
require_confirm(args, "ec2 start")
if is_dry_run(args):
return {"would_start": args.instance_id}
return ec2_svc.start_instance(session, args.instance_id)
if op == "stop":
require_confirm(args, "ec2 stop")
if is_dry_run(args):
return {"would_stop": args.instance_id}
return ec2_svc.stop_instance(session, args.instance_id)
if op == "terminate":
require_confirm_delete(args, "ec2 terminate")
if is_dry_run(args):
return {"would_terminate": args.instance_id}
return ec2_svc.terminate_instance(session, args.instance_id)
if op == "resize":
require_confirm(args, "ec2 resize (stops + restarts the instance)")
if is_dry_run(args):
return {"would_resize": args.instance_id, "to": args.instance_type}
return ec2_svc.resize_instance(
session, args.instance_id, instance_type=args.instance_type
)
if op == "alloc-eip":
require_confirm(args, "ec2 alloc-eip")
if is_dry_run(args):
return {"would_allocate_eip": True}
return ec2_svc.allocate_eip(session)
if op == "associate-eip":
require_confirm(args, "ec2 associate-eip")
if is_dry_run(args):
return {"would_associate": {"allocation_id": args.allocation_id,
"instance_id": args.instance_id}}
return ec2_svc.associate_eip(
session, allocation_id=args.allocation_id, instance_id=args.instance_id,
)
if op == "release-eip":
require_confirm_delete(args, "ec2 release-eip")
if is_dry_run(args):
return {"would_release_eip": args.allocation_id}
return ec2_svc.release_eip(session, args.allocation_id)
def _dispatch_s3(session, args):
op = args.s3_op
if op == "ls-buckets":
return s3_svc.list_buckets(session)
if op == "ls":
return s3_svc.list_objects(
session, args.bucket, prefix=args.prefix, max_items=args.max_items
)
if op == "head":
return s3_svc.head_object(session, args.bucket, args.key)
if op == "get":
return s3_svc.get_object(
session, args.bucket, args.key, out_path=Path(args.out_path)
)
if op == "put":
require_confirm(args, "s3 put")
if is_dry_run(args):
return {"would_upload": args.src_path, "to": f"s3://{args.bucket}/{args.key}"}
return s3_svc.put_object(
session, args.bucket, args.key, src_path=Path(args.src_path)
)
if op == "rm":
require_confirm_delete(args, "s3 rm")
if is_dry_run(args):
return {"would_delete": f"s3://{args.bucket}/{args.key}"}
return s3_svc.delete_object(session, args.bucket, args.key)
if op == "public-status":
return s3_svc.public_access_status(session, args.bucket)
def _dispatch_rds(session, args):
op = args.rds_op
if op == "list":
return rds_svc.list_instances(session, customer=args.customer)
if op == "describe":
return rds_svc.describe_instance(session, args.instance_id)
if op == "snapshot":
require_confirm(args, "rds snapshot")
if is_dry_run(args):
return {"would_snapshot": args.instance_id, "name": args.snapshot_id}
return rds_svc.create_snapshot(
session, instance_id=args.instance_id, snapshot_id=args.snapshot_id
)
if op == "list-snapshots":
return rds_svc.list_snapshots(session, instance_id=args.instance_id)
def _dispatch_lambda(session, args):
op = args.lambda_op
if op == "list":
return lambda_svc.list_functions(session, customer=args.customer)
if op == "get":
return lambda_svc.get_function(session, args.name)
if op == "invoke":
require_confirm(args, "lambda invoke")
if is_dry_run(args):
return {"would_invoke": args.name}
payload = _parse_payload_arg(args.payload)
return lambda_svc.invoke(
session, args.name, payload=payload, invocation_type=args.invocation_type
)
if op == "logs":
return lambda_svc.get_logs(
session, args.name, since=args.since, limit=args.limit
)
def _dispatch_vpc(session, args):
op = args.vpc_op
if op == "list":
return vpc_svc.list_vpcs(session, customer=args.customer)
if op == "subnets":
return vpc_svc.list_subnets(
session, vpc_id=args.vpc_id, customer=args.customer
)
if op == "route-tables":
return vpc_svc.list_route_tables(session, vpc_id=args.vpc_id)
if op == "nat":
return vpc_svc.list_nat_gateways(session, vpc_id=args.vpc_id)
def _dispatch_route53(session, args):
op = args.r53_op
if op == "zones":
return route53_svc.list_zones(session)
if op == "records":
return route53_svc.list_records(session, args.zone_id)
if op == "upsert":
require_confirm(args, "route53 upsert")
if is_dry_run(args):
return {"would_upsert": {"zone_id": args.zone_id, "name": args.name,
"type": args.type, "values": args.values}}
return route53_svc.upsert_record(
session, zone_id=args.zone_id, name=args.name, type=args.type,
values=args.values, ttl=args.ttl,
)
if op == "delete":
require_confirm_delete(args, "route53 delete")
if is_dry_run(args):
return {"would_delete": {"zone_id": args.zone_id, "name": args.name,
"type": args.type, "values": args.values}}
return route53_svc.delete_record(
session, zone_id=args.zone_id, name=args.name, type=args.type,
values=args.values, ttl=args.ttl,
)
def _dispatch_cloudwatch(session, args):
op = args.cw_op
if op == "log-groups":
return cloudwatch_svc.list_log_groups(session, name_prefix=args.name_prefix)
if op == "logs":
return cloudwatch_svc.tail_logs(
session, args.log_group, since=args.since, limit=args.limit,
filter_pattern=args.filter_pattern,
)
if op == "metric":
return cloudwatch_svc.metric_statistics(
session, namespace=args.namespace, metric_name=args.metric_name,
days=args.days, period_seconds=args.period_seconds,
)
def _dispatch_ecr(session, args):
op = args.ecr_op
if op == "list":
return ecr_svc.list_repos(session)
if op == "images":
return ecr_svc.list_images(session, args.repo, limit=args.limit)
if op == "login":
return ecr_svc.login_command(session)
def _dispatch_eks(session, args):
op = args.eks_op
if op == "list":
return eks_svc.list_clusters(session, customer=args.customer)
if op == "kubeconfig":
require_confirm(args, "eks kubeconfig (writes file)")
if is_dry_run(args):
return {"would_write_kubeconfig_for": args.cluster_name}
path = Path(args.kubeconfig_path) if args.kubeconfig_path else None
return eks_svc.write_kubeconfig(session, args.cluster_name, kubeconfig_path=path)
def _dispatch_cost(session, args):
op = args.cost_op
if op == "last-30d":
return cost_svc.total_cost(session, days=args.days)
if op == "by-service":
return cost_svc.by_service(session, days=args.days)
if op == "by-tag":
return cost_svc.by_tag(session, tag_key=args.key, days=args.days)
if op == "report":
return cost_report_intent.per_customer(
session, customer=args.customer, days=args.days
)
def _dispatch_jumphost(session, args):
op = args.jh_op
if op == "provision":
if not is_dry_run(args):
require_confirm(args, "jumphost provision")
return jumphost_intent.provision(
session, customer=args.customer, allowed_ips=args.allowed_ips,
instance_type=args.instance_type, environment=args.environment,
dry_run=is_dry_run(args),
)
if op == "teardown":
if not is_dry_run(args):
require_confirm_delete(args, "jumphost teardown")
return jumphost_intent.teardown(
session, customer=args.customer, dry_run=is_dry_run(args)
)
if op == "terraform":
return terraform_intent.render_jumphost(
customer=args.customer, allowed_ips=args.allowed_ips,
instance_type=args.instance_type, environment=args.environment,
out_dir=Path(args.out_dir) if args.out_dir else None,
)
def _dispatch_inventory(session, args):
if args.customer:
return inventory_intent.by_customer(session, customer=args.customer)
if args.tag_key and args.tag_value:
return inventory_intent.by_tag_value(
session, tag_key=args.tag_key, tag_value=args.tag_value
)
raise ValueError(
"inventory: pass --customer NAME or --tag-key KEY --tag-value VALUE"
)
def _dispatch_cleanup(session, args):
op = args.cleanup_op
if op == "untagged":
return cleanup_intent.find_untagged(session)
if op == "auto":
require_confirm_delete(args, "cleanup auto")
if is_dry_run(args):
preview = cleanup_intent.find_untagged(session)
preview["dry_run"] = True
return preview
return cleanup_intent.auto_delete_untagged(
session, older_than_days=args.older_than_days
)
def _dispatch_terraform(args):
op = args.tf_op
if op == "jumphost":
return terraform_intent.render_jumphost(
customer=args.customer, allowed_ips=args.allowed_ips,
instance_type=args.instance_type, environment=args.environment,
out_dir=Path(args.out_dir) if args.out_dir else None,
)
def _dispatch_bootstrap(session, args):
op = args.bootstrap_op
if op == "iam-admin":
require_confirm(args, "bootstrap iam-admin")
if is_dry_run(args):
return {
"would_create_user": args.username,
"policy": "arn:aws:iam::aws:policy/AdministratorAccess",
"would_update_profile": args.update_profile,
}
return bootstrap_intent.create_admin_user(
session,
username=args.username,
update_profile=args.update_profile,
region=args.region,
create_console_password=args.with_console_password,
)
# ---- Helpers ---------------------------------------------------------------
def _parse_payload_arg(payload: str | None) -> Any:
if payload is None:
return None
if payload.startswith("@"):
path = Path(payload[1:]).expanduser()
return json.loads(path.read_text())
try:
return json.loads(payload)
except json.JSONDecodeError:
return payload # raw string
def _columns_for(args):
cmd = args.command
if cmd == "ec2" and getattr(args, "ec2_op", None) == "list":
return ["instance_id", "state", "instance_type", "public_ip", "private_ip", "key_name"]
if cmd == "ec2" and getattr(args, "ec2_op", None) == "addresses":
return ["allocation_id", "public_ip", "association_id", "instance_id"]
if cmd == "iam" and getattr(args, "iam_op", None) == "list-users":
return ["user_name", "user_id", "arn", "created"]
if cmd == "iam" and getattr(args, "iam_op", None) == "list-roles":
return ["role_name", "role_id", "arn", "created"]
if cmd == "iam" and getattr(args, "iam_op", None) == "list-policies":
return ["policy_name", "arn", "attachment_count"]
if cmd == "cost" and getattr(args, "cost_op", None) == "by-service":
return ["service", "amount", "unit"]
if cmd == "s3" and getattr(args, "s3_op", None) == "ls-buckets":
return ["name", "region", "created"]
if cmd == "s3" and getattr(args, "s3_op", None) == "ls":
return ["key", "size", "last_modified", "storage_class"]
if cmd == "rds" and getattr(args, "rds_op", None) == "list":
return ["id", "engine", "engine_version", "status", "class", "endpoint"]
if cmd == "lambda" and getattr(args, "lambda_op", None) == "list":
return ["name", "runtime", "memory_mb", "timeout_s", "last_modified"]
if cmd == "vpc" and getattr(args, "vpc_op", None) == "list":
return ["vpc_id", "cidr", "is_default", "state"]
if cmd == "vpc" and getattr(args, "vpc_op", None) == "subnets":
return ["subnet_id", "vpc_id", "cidr", "az", "available_ips"]
if cmd == "route53" and getattr(args, "r53_op", None) == "zones":
return ["zone_id", "name", "private", "record_count"]
if cmd == "ecr" and getattr(args, "ecr_op", None) == "list":
return ["name", "uri", "image_tag_mutability", "scan_on_push"]
if cmd == "ecr" and getattr(args, "ecr_op", None) == "images":
return ["tags", "pushed", "size_mb", "digest"]
if cmd == "eks" and getattr(args, "eks_op", None) == "list":
return ["name", "status", "version", "endpoint"]
return None
def _id_key_for(args):
cmd = args.command
if cmd == "ec2" and getattr(args, "ec2_op", None) == "list":
return "instance_id"
if cmd == "ec2" and getattr(args, "ec2_op", None) == "addresses":
return "allocation_id"
if cmd == "s3" and getattr(args, "s3_op", None) == "ls-buckets":
return "name"
if cmd == "s3" and getattr(args, "s3_op", None) == "ls":
return "key"
if cmd == "rds" and getattr(args, "rds_op", None) == "list":
return "id"
if cmd == "lambda" and getattr(args, "lambda_op", None) == "list":
return "name"
if cmd == "ecr" and getattr(args, "ecr_op", None) == "list":
return "name"
if cmd == "eks" and getattr(args, "eks_op", None) == "list":
return "name"
return "id"
if __name__ == "__main__":
sys.exit(main())
"""AWS profile + session resolution.
Honors AWS-native conventions: ~/.aws/credentials, ~/.aws/config, env vars,
IAM Identity Center / SSO. The skill is a thin layer above boto3; we don't
store credentials ourselves.
Profile naming convention for this skill:
- "epoch" : default Epoch AWS account (single-account model)
- "<customer>": optional customer-specific profile (cross-account, future)
"""
from __future__ import annotations
import configparser
import os
import sys
from pathlib import Path
from typing import Optional
import boto3
from botocore.exceptions import (
ClientError,
NoCredentialsError,
ProfileNotFound,
SSOTokenLoadError,
)
# Default profile for this skill. Can be overridden by --profile or AWS_PROFILE.
DEFAULT_PROFILE = "epoch"
AWS_DIR = Path.home() / ".aws"
AWS_CREDENTIALS = AWS_DIR / "credentials"
AWS_CONFIG = AWS_DIR / "config"
class AuthError(RuntimeError):
"""Raised when auth setup is missing or invalid. Caller should print
the message and suggest `aws_skill.py setup`."""
def resolve_profile(profile: Optional[str]) -> str:
"""Pick the profile to use. Precedence:
1. explicit arg
2. AWS_PROFILE env
3. DEFAULT_PROFILE if it exists locally
4. 'default' if it exists locally
5. DEFAULT_PROFILE (caller will get a useful error if missing)
"""
if profile:
return profile
env = os.environ.get("AWS_PROFILE")
if env:
return env
local = set(list_local_profiles())
if DEFAULT_PROFILE in local:
return DEFAULT_PROFILE
if "default" in local:
return "default"
return DEFAULT_PROFILE
def list_local_profiles() -> list[str]:
"""Profiles configured in ~/.aws/credentials and ~/.aws/config."""
profiles: set[str] = set()
if AWS_CREDENTIALS.exists():
cfg = configparser.ConfigParser()
cfg.read(AWS_CREDENTIALS)
profiles.update(cfg.sections())
if AWS_CONFIG.exists():
cfg = configparser.ConfigParser()
cfg.read(AWS_CONFIG)
for section in cfg.sections():
# ~/.aws/config uses "[profile NAME]" except for [default]
if section == "default":
profiles.add("default")
elif section.startswith("profile "):
profiles.add(section[len("profile ") :])
return sorted(profiles)
def profile_exists(profile: str) -> bool:
return profile in list_local_profiles()
def get_session(
profile: Optional[str] = None,
region: Optional[str] = None,
) -> boto3.Session:
"""Build a boto3 Session for the requested profile + region.
Raises AuthError with a helpful message if the profile isn't configured
or credentials can't be resolved.
"""
chosen = resolve_profile(profile)
if not profile_exists(chosen):
local = list_local_profiles()
if not local:
raise AuthError(
"No AWS profiles configured on this machine. "
"Run `python3 ~/.claude/skills/aws-skill/aws_skill.py setup` "
"to initialize the Epoch profile."
)
raise AuthError(
f"AWS profile '{chosen}' not found. "
f"Available: {', '.join(local)}. "
f"Either pass `--profile <name>` or run "
f"`python3 ~/.claude/skills/aws-skill/aws_skill.py setup` "
f"to add the '{chosen}' profile."
)
try:
session = boto3.Session(profile_name=chosen, region_name=region)
except ProfileNotFound as e:
raise AuthError(str(e)) from e
# Eagerly resolve credentials so failures surface here, not at first API call.
try:
creds = session.get_credentials()
if creds is None:
raise AuthError(
f"AWS profile '{chosen}' is configured but resolved to no "
"credentials. Likely an expired SSO token — try "
"`aws sso login --profile " + chosen + "`."
)
except SSOTokenLoadError as e:
raise AuthError(
f"SSO token expired or missing for profile '{chosen}'. "
f"Run `aws sso login --profile {chosen}`."
) from e
except NoCredentialsError as e:
raise AuthError(
f"No credentials resolvable for profile '{chosen}'. "
"Check ~/.aws/credentials and ~/.aws/config."
) from e
return session
def get_account_id(session: boto3.Session) -> str:
"""Return the 12-digit account ID for the session's caller identity."""
sts = session.client("sts")
return sts.get_caller_identity()["Account"]
def whoami(session: boto3.Session) -> dict:
"""STS GetCallerIdentity result, with account alias if available."""
sts = session.client("sts")
identity = sts.get_caller_identity()
out = {
"account": identity["Account"],
"arn": identity["Arn"],
"user_id": identity["UserId"],
}
# Try for account alias (informational; requires iam:ListAccountAliases).
try:
iam = session.client("iam")
aliases = iam.list_account_aliases().get("AccountAliases", [])
if aliases:
out["account_alias"] = aliases[0]
except ClientError:
pass
out["region"] = session.region_name or "<unset>"
out["profile"] = session.profile_name
return out
def setup_wizard() -> int:
"""Interactive setup. Offers three auth methods:
1. `aws login` — browser-based AWS Console session (easiest, AWS CLI v2.13+).
2. `aws configure sso` — IAM Identity Center / SSO flow.
3. `aws configure` — manual access key entry.
Returns process exit code: 0 on success, non-zero on failure.
"""
import subprocess
print("=" * 64)
print(" AWS Skill — Setup Wizard")
print("=" * 64)
print()
if not AWS_DIR.exists():
print(f"Creating {AWS_DIR}/ ...")
AWS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
existing = list_local_profiles()
if existing:
print(f"Existing profiles found: {', '.join(existing)}")
else:
print("No existing AWS profiles found.")
print()
default_name = DEFAULT_PROFILE
suggestion = (
f"[{default_name}]"
if default_name not in existing
else f"[reconfigure {default_name}]"
)
name = input(f"Profile name to configure {suggestion}: ").strip() or default_name
if not name:
print("Aborted: empty profile name.")
return 1
if name in existing:
confirm = input(
f"Profile '{name}' already exists — overwrite? [y/N] "
).strip().lower()
if confirm != "y":
print("Aborted.")
return 0
print()
print("Pick an auth method:")
print(" [1] aws login (browser flow, easiest; AWS CLI v2.13+)")
print(" [2] aws configure sso (IAM Identity Center / SSO)")
print(" [3] aws configure (manual access key entry)")
print()
choice = input("Choice [1]: ").strip() or "1"
print()
if choice == "1":
cmd = ["aws", "login", "--profile", name]
method = "aws login"
elif choice == "2":
cmd = ["aws", "configure", "sso", "--profile", name]
method = "aws configure sso"
elif choice == "3":
cmd = ["aws", "configure", "--profile", name]
method = "aws configure"
else:
print(f"Unknown choice '{choice}'. Aborting.")
return 1
print(f"Running `{' '.join(cmd)}` ...")
print()
try:
rc = subprocess.call(cmd)
except FileNotFoundError:
print(
"AWS CLI not found in PATH. Install it: "
"`brew install awscli` or "
"see https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html"
)
return 127
if rc != 0:
print(f"`{method}` exited with code {rc}.")
if method == "aws login":
print(
"Hint: `aws login` requires AWS CLI v2.13+. "
"Try option [2] (SSO) or [3] (access keys), or upgrade with "
"`brew upgrade awscli`."
)
return rc
print()
print("Validating credentials ...")
try:
session = get_session(profile=name)
identity = whoami(session)
except AuthError as e:
print(f"Validation failed: {e}", file=sys.stderr)
return 2
except ClientError as e:
print(f"AWS rejected the credentials: {e}", file=sys.stderr)
return 2
print()
print(f" Account: {identity['account']}")
if "account_alias" in identity:
print(f" Account alias: {identity['account_alias']}")
print(f" ARN: {identity['arn']}")
print(f" Region: {identity['region']}")
print(f" Profile: {identity['profile']}")
print()
print(f"Profile '{name}' is configured and reachable. Setup complete.")
# Friendly nudge if the caller landed on root credentials
if ":root" in identity.get("arn", ""):
print()
print(
"Note: you authenticated as the AWS account root. Root creds work "
"but are over-privileged for routine ops. Consider creating an IAM "
"user (or IAM Identity Center user) and re-running this wizard "
"with those credentials when you have a moment."
)
return 0
"""Write/delete safety guardrails.
Convention:
- reads are free
- writes (start/stop/put/provision/associate-eip/...) require --confirm
- deletes (terminate/teardown/rm/delete/...) require --confirm-delete
(separate flag, intentionally — prevents `--confirm` muscle-memory
from green-lighting destructive ops by accident)
"""
from __future__ import annotations
import sys
from typing import Optional
class ConfirmationRequired(RuntimeError):
"""Raised when an operation needs --confirm or --confirm-delete and didn't get it."""
def require_confirm(args, op: str) -> None:
"""For mutating ops that aren't deletes."""
if not getattr(args, "confirm", False):
raise ConfirmationRequired(
f"Operation '{op}' is a write and requires --confirm. "
f"Re-run with --confirm to execute, or --dry-run to see the plan."
)
def require_confirm_delete(args, op: str) -> None:
"""For destructive ops. Stricter than --confirm."""
if not getattr(args, "confirm_delete", False):
raise ConfirmationRequired(
f"Operation '{op}' is destructive and requires --confirm-delete. "
f"This is intentionally a different flag from --confirm. "
f"Re-run with --confirm-delete to execute, or --dry-run to preview."
)
def is_dry_run(args) -> bool:
return bool(getattr(args, "dry_run", False))
"""Security audit intent.
Looks for common AWS misconfigurations:
- Security groups with 0.0.0.0/0 ingress on non-web ports
- Public S3 buckets (per get_public_access_block + ACL)
- IAM access keys older than N days (default 90)
- IAM users without MFA
- RDS instances with publicly accessible = true
- Untagged resources (delegates to cleanup.find_untagged for the count)
Returns a structured report with severities. No mutating operations —
this is observability only.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import boto3
from botocore.exceptions import ClientError
from ..services import s3 as s3_svc
from .cleanup import find_untagged
# Web-style ports that 0.0.0.0/0 ingress is usually intentional on
WEB_PORTS = {80, 443}
def run(session: boto3.Session, *, key_age_days: int = 90) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
findings.extend(_audit_security_groups(session))
findings.extend(_audit_s3_buckets(session))
findings.extend(_audit_iam_keys(session, age_days=key_age_days))
findings.extend(_audit_iam_mfa(session))
findings.extend(_audit_rds_public(session))
# Tag cleanliness summary (don't enumerate; just count)
try:
untagged = find_untagged(session)
if any(untagged["summary"].values()):
findings.append(
{
"severity": "low",
"category": "tagging",
"title": "Resources missing required tags",
"detail": untagged["summary"],
"recommendation": (
"Run `aws_skill.py cleanup untagged` for full list."
),
}
)
except ClientError:
pass
return {
"checked_at": datetime.utcnow().isoformat() + "Z",
"summary": _severity_counts(findings),
"findings": findings,
}
# ---- Security groups -------------------------------------------------------
def _audit_security_groups(session: boto3.Session) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
ec2 = session.client("ec2")
for sg in ec2.describe_security_groups().get("SecurityGroups", []):
for perm in sg.get("IpPermissions", []) or []:
from_p = perm.get("FromPort")
to_p = perm.get("ToPort")
for r in perm.get("IpRanges", []) or []:
if r.get("CidrIp") == "0.0.0.0/0":
# All-port ranges (None or 0–65535) are always severe
if from_p is None or (from_p == 0 and to_p == 65535):
sev = "high"
elif from_p in WEB_PORTS and to_p in WEB_PORTS:
sev = "info"
elif from_p == 22:
sev = "high"
else:
sev = "medium"
findings.append(
{
"severity": sev,
"category": "security_group",
"title": "Open ingress from 0.0.0.0/0",
"resource": sg.get("GroupId"),
"name": sg.get("GroupName"),
"vpc_id": sg.get("VpcId"),
"port_range": (
f"{from_p}-{to_p}" if from_p is not None else "all"
),
"protocol": perm.get("IpProtocol"),
"recommendation": (
"Restrict CIDR to a known IP range or remove "
"the rule if unintended."
),
}
)
return findings
# ---- S3 --------------------------------------------------------------------
def _audit_s3_buckets(session: boto3.Session) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
s3 = session.client("s3")
for b in s3.list_buckets().get("Buckets", []):
name = b.get("Name")
try:
status = s3_svc.public_access_status(session, name)
except ClientError as e:
findings.append(
{
"severity": "low",
"category": "s3",
"title": "Could not assess bucket public-access",
"resource": name,
"detail": str(e),
}
)
continue
if status.get("acl_public_grants"):
findings.append(
{
"severity": "high",
"category": "s3",
"title": "Bucket ACL grants public access",
"resource": name,
"grants": status.get("acl_public_grants"),
"recommendation": (
"Apply a public access block; review whether the "
"public grant is intentional (CDN-fronted asset bucket?)."
),
}
)
elif status.get("public_access_block") is None:
findings.append(
{
"severity": "medium",
"category": "s3",
"title": "Bucket has no public-access-block configuration",
"resource": name,
"recommendation": (
"Apply BlockPublicAcls / IgnorePublicAcls / "
"BlockPublicPolicy / RestrictPublicBuckets all = true."
),
}
)
return findings
# ---- IAM --------------------------------------------------------------------
def _audit_iam_keys(
session: boto3.Session, *, age_days: int = 90
) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
iam = session.client("iam")
cutoff = datetime.now(timezone.utc) - timedelta(days=age_days)
for u in iam.list_users().get("Users", []):
try:
keys = iam.list_access_keys(UserName=u["UserName"]).get(
"AccessKeyMetadata", []
)
except ClientError:
continue
for k in keys:
if k.get("Status") != "Active":
continue
created = k.get("CreateDate")
if not created:
continue
if created < cutoff:
age_d = (datetime.now(timezone.utc) - created).days
findings.append(
{
"severity": "medium" if age_d < 365 else "high",
"category": "iam_key",
"title": f"Access key older than {age_days} days",
"resource": k.get("AccessKeyId"),
"user": u.get("UserName"),
"age_days": age_d,
"recommendation": (
"Rotate the key. If unused, deactivate or delete."
),
}
)
return findings
def _audit_iam_mfa(session: boto3.Session) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
iam = session.client("iam")
for u in iam.list_users().get("Users", []):
try:
devices = iam.list_mfa_devices(UserName=u["UserName"]).get(
"MFADevices", []
)
except ClientError:
continue
if not devices:
# Check if user can log in with a password (no password = no MFA needed)
try:
iam.get_login_profile(UserName=u["UserName"])
has_password = True
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "NoSuchEntity":
has_password = False
else:
has_password = True
if has_password:
findings.append(
{
"severity": "high",
"category": "iam_mfa",
"title": "IAM user has password but no MFA",
"resource": u.get("UserName"),
"recommendation": "Enable MFA or remove the password.",
}
)
return findings
# ---- RDS -------------------------------------------------------------------
def _audit_rds_public(session: boto3.Session) -> list[dict[str, Any]]:
findings: list[dict[str, Any]] = []
rds = session.client("rds")
paginator = rds.get_paginator("describe_db_instances")
for page in paginator.paginate():
for inst in page.get("DBInstances", []):
if inst.get("PubliclyAccessible"):
findings.append(
{
"severity": "high",
"category": "rds",
"title": "RDS instance is publicly accessible",
"resource": inst.get("DBInstanceIdentifier"),
"engine": inst.get("Engine"),
"endpoint": (
inst.get("Endpoint", {}).get("Address")
if inst.get("Endpoint")
else None
),
"recommendation": (
"Set PubliclyAccessible=false and restrict access "
"via security group / VPC peering / private link."
),
}
)
return findings
def _severity_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
counts = {"high": 0, "medium": 0, "low": 0, "info": 0, "total": len(findings)}
for f in findings:
sev = f.get("severity", "info")
counts[sev] = counts.get(sev, 0) + 1
return counts
"""Bootstrap intent: create the right IAM identity off root.
Use case: you authenticated as the AWS account root (e.g. via
`aws login`) and want to move to a proper IAM user for ongoing skill use.
This intent creates an admin user, attaches AdministratorAccess, generates
access keys, and (optionally) writes them into ~/.aws/credentials as a
named profile.
After running, the recommended next step is to:
1. Verify the new profile works: `aws_skill.py iam who-am-i --profile <name>`
2. Sign out of root in the AWS Console
3. Add MFA to the new IAM user (console or `aws iam enable-mfa-device`)
"""
from __future__ import annotations
import configparser
from pathlib import Path
from typing import Any, Optional
import boto3
from botocore.exceptions import ClientError
from ..auth import AWS_CREDENTIALS, AWS_DIR, AWS_CONFIG
ADMIN_POLICY_ARN = "arn:aws:iam::aws:policy/AdministratorAccess"
def create_admin_user(
session: boto3.Session,
*,
username: str,
update_profile: Optional[str] = None,
region: Optional[str] = None,
create_console_password: bool = False,
) -> dict[str, Any]:
"""Create an IAM user with AdministratorAccess and a fresh access key.
Args:
session: boto3 session (must have iam:* — typically root on first
bootstrap).
username: IAM user name to create.
update_profile: if provided, write the new keys into ~/.aws/credentials
under this profile name. The profile entry replaces any existing
entry of the same name.
region: default region to associate with the profile (only used if
update_profile is set).
create_console_password: if True, generate a console login password
and return it. The user must change it on first sign-in.
Returns: created identity, access keys, and (if update_profile) the
path of the credentials file we updated.
"""
iam = session.client("iam")
# 1) Create user (idempotent-ish: if it exists, that's fine, we'll just
# add the policy and a new key).
user_existed = False
try:
iam.create_user(UserName=username)
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "EntityAlreadyExists":
user_existed = True
else:
raise
user_arn = iam.get_user(UserName=username)["User"]["Arn"]
# 2) Attach AdministratorAccess (idempotent — attach is a no-op if already
# attached).
iam.attach_user_policy(UserName=username, PolicyArn=ADMIN_POLICY_ARN)
# 3) Create access key
key_resp = iam.create_access_key(UserName=username)
key = key_resp.get("AccessKey", {})
access_key_id = key.get("AccessKeyId")
secret_access_key = key.get("SecretAccessKey")
# 4) Optionally create console login profile
console_password: Optional[str] = None
if create_console_password:
import secrets
console_password = secrets.token_urlsafe(20)
try:
iam.create_login_profile(
UserName=username,
Password=console_password,
PasswordResetRequired=True,
)
except ClientError as e:
if e.response.get("Error", {}).get("Code") == "EntityAlreadyExists":
console_password = None # don't shout a stale password
else:
raise
out: dict[str, Any] = {
"user_name": username,
"user_arn": user_arn,
"user_existed_before": user_existed,
"policy_attached": ADMIN_POLICY_ARN,
"access_key_id": access_key_id,
"secret_access_key": secret_access_key,
"key_displayed_once": (
"AWS only shows the secret access key on creation. Save it now."
),
}
if console_password:
out["console_password"] = console_password
out["console_password_reset_required"] = True
if update_profile:
path = _write_profile(
profile=update_profile,
access_key_id=access_key_id,
secret_access_key=secret_access_key,
region=region or session.region_name,
)
out["credentials_file_updated"] = str(path)
out["next_step"] = (
f"Re-run any aws-skill command with `--profile {update_profile}` "
"to use the new identity, or set AWS_PROFILE."
)
else:
out["next_step"] = (
"Run `aws configure --profile <name>` and paste the access key "
"above, or pass --update-profile NAME on the bootstrap command "
"to do this automatically."
)
out["recommended_followups"] = [
f"Verify the new profile works: aws_skill.py iam who-am-i "
f"--profile {update_profile or '<name>'}",
"Add MFA to the new user via the AWS Console (Security credentials → "
"Assigned MFA device → Assign).",
"Sign out of the AWS Console as root and stop using root creds for "
"day-to-day work.",
]
return out
def _write_profile(
*,
profile: str,
access_key_id: str,
secret_access_key: str,
region: Optional[str],
) -> Path:
"""Write/update the credentials and config files for a profile.
Replaces an existing profile in place.
"""
AWS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
# ~/.aws/credentials
creds = configparser.ConfigParser()
if AWS_CREDENTIALS.exists():
creds.read(AWS_CREDENTIALS)
if profile in creds:
creds.remove_section(profile)
creds[profile] = {
"aws_access_key_id": access_key_id,
"aws_secret_access_key": secret_access_key,
}
with AWS_CREDENTIALS.open("w") as f:
creds.write(f)
AWS_CREDENTIALS.chmod(0o600)
# ~/.aws/config (region + output for the named profile)
if region:
cfg = configparser.ConfigParser()
if AWS_CONFIG.exists():
cfg.read(AWS_CONFIG)
section = "default" if profile == "default" else f"profile {profile}"
if section in cfg:
cfg.remove_section(section)
cfg[section] = {
"region": region,
"output": "json",
}
with AWS_CONFIG.open("w") as f:
cfg.write(f)
AWS_CONFIG.chmod(0o600)
return AWS_CREDENTIALS
"""Cleanup intent: find resources missing required tags.
Walks common services looking for resources that don't carry our required
tag set (Customer/Project/Owner/Environment/ManagedBy). Reports them by
default; --confirm-delete actually terminates (only safe types).
What "safe to auto-delete" means here:
- Stopped EC2 instances older than 7 days, untagged
- Unattached Elastic IPs (cost real money), untagged
- Empty security groups (not 'default'), untagged
Everything else is reported, not auto-acted-on.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import boto3
from ..tagging import REQUIRED_TAG_KEYS, missing_required_tags, tags_from_aws
def find_untagged(session: boto3.Session) -> dict[str, Any]:
"""Find resources missing one or more required tags."""
ec2 = session.client("ec2")
# EC2 instances
instances_missing: list[dict[str, Any]] = []
paginator = ec2.get_paginator("describe_instances")
for page in paginator.paginate():
for reservation in page.get("Reservations", []):
for inst in reservation.get("Instances", []):
missing = missing_required_tags(inst.get("Tags"))
if missing:
instances_missing.append(
{
"type": "ec2_instance",
"id": inst.get("InstanceId"),
"state": inst.get("State", {}).get("Name"),
"launch_time": inst.get("LaunchTime"),
"missing_tags": missing,
}
)
# Elastic IPs
eips_missing: list[dict[str, Any]] = []
for a in ec2.describe_addresses().get("Addresses", []):
missing = missing_required_tags(a.get("Tags"))
if missing:
eips_missing.append(
{
"type": "elastic_ip",
"id": a.get("AllocationId"),
"public_ip": a.get("PublicIp"),
"association_id": a.get("AssociationId"),
"missing_tags": missing,
}
)
# Security groups (skip default; you can't tag the default SG meaningfully)
sgs_missing: list[dict[str, Any]] = []
for sg in ec2.describe_security_groups().get("SecurityGroups", []):
if sg.get("GroupName") == "default":
continue
missing = missing_required_tags(sg.get("Tags"))
if missing:
sgs_missing.append(
{
"type": "security_group",
"id": sg.get("GroupId"),
"name": sg.get("GroupName"),
"vpc_id": sg.get("VpcId"),
"missing_tags": missing,
}
)
# Key pairs
kps_missing: list[dict[str, Any]] = []
for k in ec2.describe_key_pairs().get("KeyPairs", []):
missing = missing_required_tags(k.get("Tags"))
if missing:
kps_missing.append(
{
"type": "key_pair",
"id": k.get("KeyPairId"),
"name": k.get("KeyName"),
"missing_tags": missing,
}
)
# EBS volumes (often forgotten — and they cost money)
vols_missing: list[dict[str, Any]] = []
for v in ec2.describe_volumes().get("Volumes", []):
missing = missing_required_tags(v.get("Tags"))
if missing:
vols_missing.append(
{
"type": "ebs_volume",
"id": v.get("VolumeId"),
"size_gb": v.get("Size"),
"state": v.get("State"),
"attachments": [
a.get("InstanceId") for a in v.get("Attachments", [])
],
"missing_tags": missing,
}
)
return {
"required_tag_keys": list(REQUIRED_TAG_KEYS),
"summary": {
"ec2_instances": len(instances_missing),
"elastic_ips": len(eips_missing),
"security_groups": len(sgs_missing),
"key_pairs": len(kps_missing),
"ebs_volumes": len(vols_missing),
},
"ec2_instances": instances_missing,
"elastic_ips": eips_missing,
"security_groups": sgs_missing,
"key_pairs": kps_missing,
"ebs_volumes": vols_missing,
}
def auto_delete_untagged(
session: boto3.Session,
*,
older_than_days: int = 7,
) -> dict[str, Any]:
"""Delete the categorically-safe untagged resources.
Only acts on:
- Unattached Elastic IPs (any age — they cost money idle)
- Stopped EC2 instances older than `older_than_days`
Does NOT touch security groups, key pairs, EBS volumes, or running
instances. Use the report-only `find_untagged()` and act manually for
those.
"""
ec2 = session.client("ec2")
cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
actions: list[dict[str, Any]] = []
# Unattached EIPs
for a in ec2.describe_addresses().get("Addresses", []):
if missing_required_tags(a.get("Tags")) and not a.get("AssociationId"):
try:
ec2.release_address(AllocationId=a["AllocationId"])
actions.append(
{
"released_eip": a["AllocationId"],
"public_ip": a.get("PublicIp"),
}
)
except Exception as e:
actions.append(
{"warn": f"release_eip {a['AllocationId']}: {e}"}
)
# Stopped, untagged, old instances
paginator = ec2.get_paginator("describe_instances")
for page in paginator.paginate(
Filters=[{"Name": "instance-state-name", "Values": ["stopped"]}]
):
for reservation in page.get("Reservations", []):
for inst in reservation.get("Instances", []):
if not missing_required_tags(inst.get("Tags")):
continue
lt = inst.get("LaunchTime")
if lt and lt > cutoff:
continue
try:
ec2.terminate_instances(InstanceIds=[inst["InstanceId"]])
actions.append(
{
"terminated_instance": inst["InstanceId"],
"launch_time": lt,
}
)
except Exception as e:
actions.append(
{"warn": f"terminate {inst['InstanceId']}: {e}"}
)
return {
"policy": {
"older_than_days": older_than_days,
"rules": [
"Release unattached, untagged Elastic IPs",
f"Terminate stopped, untagged EC2 instances older than {older_than_days} days",
],
},
"actions": actions,
"actions_count": len(actions),
}
"""Per-customer cost report intent.
Cost broken down by Customer tag, with per-service detail and a simple
month-over-month comparison.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Optional
import boto3
from ..services.cost import CE_REGION
def _ce(session: boto3.Session):
return session.client("ce", region_name=CE_REGION)
def _last_n_months_window(months: int) -> tuple[str, str]:
today = date.today()
start = (today.replace(day=1)) - timedelta(days=months * 31)
start = start.replace(day=1)
return start.isoformat(), today.isoformat()
def per_customer(
session: boto3.Session,
*,
customer: str,
days: int = 30,
) -> dict[str, Any]:
"""Cost detail for a single Customer tag value over the window.
Reports:
- total over the window
- breakdown by AWS service (within this customer)
- month-over-month total trend (last 6 months)
"""
ce = _ce(session)
end = date.today()
start = end - timedelta(days=days)
# Total across the window, filtered to this customer's tag
tag_filter = {
"Tags": {"Key": "Customer", "Values": [customer], "MatchOptions": ["EQUALS"]}
}
total_resp = ce.get_cost_and_usage(
TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
Filter=tag_filter,
)
total = 0.0
unit = "USD"
for r in total_resp.get("ResultsByTime", []):
amount = r.get("Total", {}).get("UnblendedCost", {})
try:
total += float(amount.get("Amount", 0))
except (TypeError, ValueError):
pass
unit = amount.get("Unit", unit)
# By-service within this customer
svc_resp = ce.get_cost_and_usage(
TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
Filter=tag_filter,
)
svc_totals: dict[str, float] = {}
for r in svc_resp.get("ResultsByTime", []):
for g in r.get("Groups", []):
keys = g.get("Keys", [])
if not keys:
continue
service = keys[0]
try:
svc_totals[service] = svc_totals.get(service, 0.0) + float(
g.get("Metrics", {}).get("UnblendedCost", {}).get("Amount", 0)
)
except (TypeError, ValueError):
continue
service_breakdown = [
{"service": s, "amount": round(v, 2), "unit": unit}
for s, v in sorted(svc_totals.items(), key=lambda kv: kv[1], reverse=True)
]
# Month-over-month last 6 months
mom_start_iso, mom_end_iso = _last_n_months_window(6)
mom_resp = ce.get_cost_and_usage(
TimePeriod={"Start": mom_start_iso, "End": mom_end_iso},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
Filter=tag_filter,
)
monthly: list[dict[str, Any]] = []
for r in mom_resp.get("ResultsByTime", []):
amount = r.get("Total", {}).get("UnblendedCost", {})
try:
value = float(amount.get("Amount", 0))
except (TypeError, ValueError):
value = 0.0
monthly.append(
{
"period_start": r.get("TimePeriod", {}).get("Start"),
"period_end": r.get("TimePeriod", {}).get("End"),
"amount": round(value, 2),
"unit": amount.get("Unit", unit),
}
)
return {
"customer": customer,
"window_days": days,
"start": start.isoformat(),
"end": end.isoformat(),
"total": round(total, 2),
"unit": unit,
"service_breakdown": service_breakdown,
"monthly_last_6": monthly,
"notes": (
"Requires the 'Customer' tag to be activated as a cost-allocation "
"tag in AWS Billing. If totals are zero, that's the likely cause."
),
}
"""Inventory intent: list everything tagged for a Customer / Owner.
v1: covers EC2 instances, Elastic IPs, security groups, key pairs.
v2: extends to S3, RDS, Lambda, etc.
"""
from __future__ import annotations
from typing import Any, Optional
import boto3
from ..services import ec2 as ec2_svc
from ..tagging import tags_from_aws
def by_customer(
session: boto3.Session,
*,
customer: str,
) -> dict[str, Any]:
"""All resources tagged Customer=<name>, grouped by service."""
instances = ec2_svc.list_instances(session, customer=customer, show_all=True)
addresses = ec2_svc.list_addresses(session, customer=customer)
security_groups = ec2_svc.list_security_groups(session, customer=customer)
return {
"customer": customer,
"region": session.region_name,
"summary": {
"ec2_instances": len(instances),
"elastic_ips": len(addresses),
"security_groups": len(security_groups),
},
"ec2_instances": instances,
"elastic_ips": addresses,
"security_groups": security_groups,
}
def by_tag_value(
session: boto3.Session,
*,
tag_key: str,
tag_value: str,
) -> list[dict[str, Any]]:
"""Generic tag-based listing using EC2-flavored Filters.
Note: this only covers services whose APIs support tag:KEY filters.
Some services (Lambda, RDS) use list_tags_for_resource and need separate
handling — added in v2.
"""
ec2_client = session.client("ec2")
filters = [{"Name": f"tag:{tag_key}", "Values": [tag_value]}]
out: list[dict[str, Any]] = []
# EC2 instances
paginator = ec2_client.get_paginator("describe_instances")
for page in paginator.paginate(Filters=filters):
for reservation in page.get("Reservations", []):
for inst in reservation.get("Instances", []):
out.append(
{
"type": "ec2_instance",
"id": inst.get("InstanceId"),
"state": inst.get("State", {}).get("Name"),
"public_ip": inst.get("PublicIpAddress"),
"tags": tags_from_aws(inst.get("Tags")),
}
)
# Elastic IPs
addrs = ec2_client.describe_addresses(Filters=filters)
for a in addrs.get("Addresses", []):
out.append(
{
"type": "elastic_ip",
"id": a.get("AllocationId"),
"public_ip": a.get("PublicIp"),
"association_id": a.get("AssociationId"),
"tags": tags_from_aws(a.get("Tags")),
}
)
# Security groups
sgs = ec2_client.describe_security_groups(Filters=filters)
for s in sgs.get("SecurityGroups", []):
out.append(
{
"type": "security_group",
"id": s.get("GroupId"),
"name": s.get("GroupName"),
"vpc_id": s.get("VpcId"),
"tags": tags_from_aws(s.get("Tags")),
}
)
# Key pairs (describe_key_pairs supports tag filters)
kps = ec2_client.describe_key_pairs(Filters=filters)
for k in kps.get("KeyPairs", []):
out.append(
{
"type": "key_pair",
"id": k.get("KeyPairId"),
"name": k.get("KeyName"),
"tags": tags_from_aws(k.get("Tags")),
}
)
return out
"""Jumphost intent: provision and teardown a customer-tagged jump host.
Composes EC2 primitives (key pair + security group + run-instance + EIP +
associate-EIP) into a single idempotent verb. Every resource carries the
required-tag set (Customer / Project / Owner / Environment / ManagedBy).
Convention: Project tag is always 'jumphost' for these resources, so
teardown can reliably find them via tag filter.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any, Optional
import boto3
from botocore.exceptions import ClientError
from .. import tagging
from ..services import ec2
JUMPHOST_PROJECT = "jumphost"
def _customer_cfg(customer: str) -> dict[str, Any]:
cfg = tagging.load_customer_config(customer)
if not cfg:
raise ValueError(
f"No customer config found at customers/{customer}.json. "
f"Copy templates/customer-config.example.json and edit."
)
return cfg
def _resource_name(prefix: str, suffix: str) -> str:
return f"{prefix}-jumphost-{suffix}"
def provision(
session: boto3.Session,
*,
customer: str,
allowed_ips: Optional[list[str]] = None,
instance_type: Optional[str] = None,
environment: str = "dev",
dry_run: bool = False,
) -> dict[str, Any]:
"""Provision a complete jump host. Idempotent in the sense that it tags
everything; re-running creates a NEW set unless you teardown first.
`allowed_ips` is a list of CIDRs that may SSH to the jumphost. If not
given, falls back to customers/<name>.json `jumphost.allowed_ingress`
(also a list).
Returns a dict with the created resource IDs and an SSH connection hint.
"""
cfg = _customer_cfg(customer)
region = cfg.get("region")
naming_prefix = cfg.get("naming_prefix", customer)
jh_cfg = cfg.get("jumphost", {})
instance_type = (
instance_type or jh_cfg.get("instance_type") or "t4g.small"
)
allowed_list = allowed_ips or jh_cfg.get("allowed_ingress") or []
if not allowed_list:
raise ValueError(
"No allowed-ingress CIDRs. Pass --allowed-ip (repeatable) or set "
f"customers/{customer}.json jumphost.allowed_ingress."
)
warnings: list[str] = []
if "0.0.0.0/0" in allowed_list:
warnings.append(
"Ingress includes 0.0.0.0/0 — SSH is open to the entire internet. "
"Acceptable here because the jumphost is key-based + fail2ban + ufw, "
"but consider restricting to specific egress IPs if you can."
)
ssh_key_path = Path(
jh_cfg.get("ssh_key_path", f"~/.ssh/aws-skill-{customer}-jumphost")
).expanduser()
tags = tagging.build_tags(
customer=customer,
project=JUMPHOST_PROJECT,
environment=environment,
)
plan = {
"customer": customer,
"region": region or session.region_name,
"instance_type": instance_type,
"allowed_ips": allowed_list,
"tags": tags,
"ssh_key_path": str(ssh_key_path),
"naming_prefix": naming_prefix,
"warnings": warnings,
"actions": [
"create EC2 key pair",
f"create security group + {len(allowed_list)} ingress rule(s) (port 22)",
"look up latest Ubuntu 22.04 LTS AMI",
"render cloud-init from templates/jumphost-userdata.sh",
"run EC2 instance",
"wait for running state",
"allocate Elastic IP",
"associate EIP to instance",
],
}
if dry_run:
plan["dry_run"] = True
return plan
# If the session region isn't set but customer config has one, build a
# new session in the right region.
if region and (session.region_name != region):
session = boto3.Session(profile_name=session.profile_name, region_name=region)
created: dict[str, Any] = {"customer": customer, "region": session.region_name}
# 1) key pair
if ssh_key_path.exists():
raise FileExistsError(
f"SSH key already exists at {ssh_key_path}. Refusing to overwrite. "
f"Either pass a different path or delete the existing file."
)
key_name = _resource_name(naming_prefix, "key")
kp = ec2.create_key_pair(
session, key_name=key_name, save_path=ssh_key_path, tags=tags
)
created["key_pair"] = kp
# 2) security group + ingress (one rule per allowed CIDR)
sg_name = _resource_name(naming_prefix, "sg")
sg = ec2.create_security_group(
session,
name=sg_name,
description=f"aws-skill jumphost SG for {customer}",
tags=tags,
)
created["security_group"] = sg
ingress_added: list[dict[str, Any]] = []
for cidr in allowed_list:
ec2.authorize_ingress(
session, group_id=sg["group_id"], cidr=cidr, port=22, protocol="tcp"
)
ingress_added.append({"cidr": cidr, "port": 22})
created["ingress"] = ingress_added
# 3) AMI
image_id = ec2.latest_ubuntu_lts_ami(session)
created["ami_id"] = image_id
# 4) user-data
user_data = _render_user_data(
hostname=_resource_name(naming_prefix, "host"),
allowed_ip=", ".join(allowed_list),
)
# 5) run instance
inst = ec2.run_instance(
session,
image_id=image_id,
instance_type=instance_type,
key_name=key_name,
security_group_ids=[sg["group_id"]],
user_data=user_data,
tags=tags,
)
created["instance"] = inst
# 6) wait for running
ec2.wait_for_instance_running(session, inst["instance_id"])
# 7) allocate + associate EIP
eip = ec2.allocate_eip(session, tags=tags)
created["eip"] = eip
assoc = ec2.associate_eip(
session,
allocation_id=eip["allocation_id"],
instance_id=inst["instance_id"],
)
created["association"] = assoc
# SSH hint
created["ssh_hint"] = (
f"ssh -i {ssh_key_path} ubuntu@{eip['public_ip']}"
)
created["next_steps"] = [
f"Verify SSH connectivity: {created['ssh_hint']}",
f"Expect ~60-120s for cloud-init (apt + fail2ban + ufw) to finish.",
f"Marker file on host: /var/log/aws-skill-jumphost-ready",
]
return created
def teardown(
session: boto3.Session,
*,
customer: str,
dry_run: bool = False,
) -> dict[str, Any]:
"""Tear down everything tagged Customer=<name> Project=jumphost.
Order matters: disassociate + release EIP first, terminate instance,
delete SG, delete key pair.
"""
cfg = _customer_cfg(customer)
region = cfg.get("region")
if region and (session.region_name != region):
session = boto3.Session(profile_name=session.profile_name, region_name=region)
instances = ec2.list_instances(
session, customer=customer, project=JUMPHOST_PROJECT, show_all=True
)
addresses = ec2.list_addresses(
session, customer=customer, project=JUMPHOST_PROJECT
)
sgs = ec2.list_security_groups(
session, customer=customer, project=JUMPHOST_PROJECT
)
plan = {
"customer": customer,
"region": session.region_name,
"instances_to_terminate": [i["instance_id"] for i in instances],
"eips_to_release": [a["allocation_id"] for a in addresses],
"security_groups_to_delete": [s["group_id"] for s in sgs],
"key_pairs_referenced": sorted({i.get("key_name") for i in instances if i.get("key_name")}),
}
if dry_run:
plan["dry_run"] = True
return plan
actions: list[dict[str, Any]] = []
# 1) disassociate + release EIPs
ec2_client = session.client("ec2")
for addr in addresses:
if addr.get("association_id"):
try:
ec2.disassociate_eip(session, addr["association_id"])
actions.append(
{"disassociated": addr["association_id"]}
)
except ClientError as e:
actions.append(
{"warn": f"disassociate {addr['association_id']}: {e}"}
)
if addr.get("allocation_id"):
try:
ec2.release_eip(session, addr["allocation_id"])
actions.append({"released_eip": addr["allocation_id"]})
except ClientError as e:
actions.append({"warn": f"release {addr['allocation_id']}: {e}"})
# 2) terminate instances
for inst in instances:
if inst["state"] in {"terminated", "shutting-down"}:
continue
try:
ec2.terminate_instance(session, inst["instance_id"])
actions.append({"terminating": inst["instance_id"]})
except ClientError as e:
actions.append({"warn": f"terminate {inst['instance_id']}: {e}"})
# Wait for instances to terminate before deleting SGs (SGs can't be
# deleted while attached).
if instances:
try:
waiter = ec2_client.get_waiter("instance_terminated")
waiter.wait(InstanceIds=[i["instance_id"] for i in instances])
actions.append({"all_instances_terminated": True})
except ClientError as e:
actions.append({"warn": f"wait_for_terminated: {e}"})
# 3) delete security groups
for sg in sgs:
try:
ec2.delete_security_group(session, sg["group_id"])
actions.append({"deleted_sg": sg["group_id"]})
except ClientError as e:
actions.append({"warn": f"delete sg {sg['group_id']}: {e}"})
# 4) delete key pairs (the ones referenced by torn-down instances)
for key_name in {i.get("key_name") for i in instances if i.get("key_name")}:
try:
ec2.delete_key_pair(session, key_name)
actions.append({"deleted_key_pair": key_name})
except ClientError as e:
actions.append({"warn": f"delete key {key_name}: {e}"})
return {
"customer": customer,
"region": session.region_name,
"plan": plan,
"actions": actions,
"ssh_key_files_local": (
"Local SSH private key files were NOT deleted. "
"Remove manually if appropriate: "
f"~/.ssh/aws-skill-{customer}-jumphost"
),
}
def _render_user_data(*, hostname: str, allowed_ip: str) -> str:
template_path = (
Path(__file__).resolve().parents[2]
/ "templates"
/ "jumphost-userdata.sh"
)
text = template_path.read_text()
return (
text.replace("{{HOSTNAME}}", hostname)
.replace("{{ALLOWED_IP}}", allowed_ip)
)
"""Terraform integration: render intent ops as .tf files.
Direction: 'terraform-out', not 'terraform-in'. We don't ingest existing
state — we just emit a Terraform configuration that recreates what the
intent op would have built. This lets the user choose whether to execute
via boto3 (fast, scriptable) or terraform (durable, declarative, drift-
aware).
Output goes to terraform/<customer>/main.tf, gitignored alongside
customers/.
The first use case is jumphost; pattern generalizes.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional
from .. import tagging
SKILL_ROOT = Path(__file__).resolve().parents[2]
TERRAFORM_DIR = SKILL_ROOT / "terraform"
def render_jumphost(
*,
customer: str,
allowed_ips: Optional[list[str]] = None,
instance_type: Optional[str] = None,
environment: str = "dev",
out_dir: Optional[Path] = None,
) -> dict[str, Any]:
"""Emit a self-contained main.tf for a customer jump host.
Mirrors what `intent.jumphost.provision()` would create:
- aws_key_pair (caller supplies the public-key material via tfvar)
- aws_security_group + aws_security_group_rule (port 22 per CIDR)
- aws_instance (Ubuntu 22.04 LTS via SSM parameter lookup)
- aws_eip + aws_eip_association
Returns: paths of files written + the suggested apply commands.
"""
cfg = tagging.load_customer_config(customer)
if not cfg:
raise ValueError(
f"customers/{customer}.json not found. Cannot render Terraform "
"without a customer config."
)
region = cfg.get("region", "us-west-2")
naming_prefix = cfg.get("naming_prefix", customer)
jh = cfg.get("jumphost", {})
instance_type = (
instance_type or jh.get("instance_type") or "t4g.small"
)
allowed_list = allowed_ips or jh.get("allowed_ingress") or []
if not allowed_list:
raise ValueError(
"No allowed-ingress CIDRs. Pass --allowed-ip (repeatable) or set "
f"customers/{customer}.json jumphost.allowed_ingress."
)
target_dir = Path(out_dir) if out_dir else (TERRAFORM_DIR / customer)
target_dir.mkdir(parents=True, exist_ok=True)
tags = tagging.build_tags(
customer=customer, project="jumphost", environment=environment
)
main_tf = _render_main_tf(
customer=customer,
naming_prefix=naming_prefix,
region=region,
instance_type=instance_type,
allowed_cidrs=allowed_list,
tags=tags,
)
variables_tf = _render_variables_tf(naming_prefix=naming_prefix)
outputs_tf = _render_outputs_tf()
user_data = _read_user_data(naming_prefix, ", ".join(allowed_list))
files: dict[str, Path] = {
"main.tf": target_dir / "main.tf",
"variables.tf": target_dir / "variables.tf",
"outputs.tf": target_dir / "outputs.tf",
"user-data.sh": target_dir / "user-data.sh",
}
files["main.tf"].write_text(main_tf)
files["variables.tf"].write_text(variables_tf)
files["outputs.tf"].write_text(outputs_tf)
files["user-data.sh"].write_text(user_data)
return {
"customer": customer,
"region": region,
"out_dir": str(target_dir),
"files": {k: str(v) for k, v in files.items()},
"next_steps": [
f"cd {target_dir}",
"terraform init",
"Generate an SSH key locally (the .tf reads its public-key path "
"from var.public_key_path).",
"terraform plan",
"terraform apply",
],
"notes": (
"This is a stand-alone Terraform module. State is local by "
"default; for shared use, configure an S3 backend. The module "
"intentionally does not import existing skill-managed resources."
),
}
# ---- Templates --------------------------------------------------------------
def _render_main_tf(
*,
customer: str,
naming_prefix: str,
region: str,
instance_type: str,
allowed_cidrs: list[str],
tags: dict[str, str],
) -> str:
tags_block = _render_tag_block(tags, indent=" ")
cidrs_tf = ", ".join(f'"{c}"' for c in allowed_cidrs)
return f"""# aws-skill / Terraform — jumphost for customer: {customer}
# Generated by lib/intent/terraform.py
#
# Mirrors what `aws_skill.py jumphost provision --customer {customer}` would create.
# Edit the variables in variables.tf, then `terraform init && terraform apply`.
terraform {{
required_providers {{
aws = {{
source = "hashicorp/aws"
version = "~> 5.0"
}}
}}
required_version = ">= 1.5"
}}
provider "aws" {{
region = "{region}"
profile = var.aws_profile
default_tags {{
tags = local.common_tags
}}
}}
locals {{
common_tags = {{
{tags_block}
}}
}}
# Latest Ubuntu 22.04 LTS ARM64 AMI (Canonical)
data "aws_ssm_parameter" "ubuntu_lts" {{
name = "/aws/service/canonical/ubuntu/server/22.04/stable/current/arm64/hvm/ebs-gp2/ami-id"
}}
resource "aws_key_pair" "jumphost" {{
key_name = "{naming_prefix}-jumphost-key"
public_key = file(var.public_key_path)
}}
resource "aws_security_group" "jumphost" {{
name = "{naming_prefix}-jumphost-sg"
description = "aws-skill jumphost SG for {customer}"
}}
resource "aws_security_group_rule" "jumphost_ssh" {{
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [{cidrs_tf}]
security_group_id = aws_security_group.jumphost.id
description = "aws-skill ingress"
}}
resource "aws_security_group_rule" "jumphost_egress_all" {{
type = "egress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.jumphost.id
}}
resource "aws_instance" "jumphost" {{
ami = data.aws_ssm_parameter.ubuntu_lts.value
instance_type = "{instance_type}"
key_name = aws_key_pair.jumphost.key_name
vpc_security_group_ids = [aws_security_group.jumphost.id]
user_data = file("${{path.module}}/user-data.sh")
root_block_device {{
volume_size = 20
volume_type = "gp3"
encrypted = true
delete_on_termination = true
}}
metadata_options {{
http_tokens = "required"
http_endpoint = "enabled"
instance_metadata_tags = "enabled"
}}
tags = {{
Name = "{naming_prefix}-jumphost"
}}
}}
resource "aws_eip" "jumphost" {{
domain = "vpc"
tags = {{
Name = "{naming_prefix}-jumphost-eip"
}}
}}
resource "aws_eip_association" "jumphost" {{
instance_id = aws_instance.jumphost.id
allocation_id = aws_eip.jumphost.id
}}
"""
def _render_variables_tf(*, naming_prefix: str) -> str:
return """variable "aws_profile" {
description = "AWS profile to use (matches ~/.aws/config)."
type = string
default = "epoch"
}
variable "public_key_path" {
description = "Path to the SSH public key file to register on the jumphost."
type = string
default = "~/.ssh/aws-skill-jumphost.pub"
}
"""
def _render_outputs_tf() -> str:
return """output "instance_id" {
value = aws_instance.jumphost.id
}
output "public_ip" {
value = aws_eip.jumphost.public_ip
}
output "ssh_hint" {
value = "ssh -i ${replace(var.public_key_path, ".pub", "")} ubuntu@${aws_eip.jumphost.public_ip}"
}
"""
def _render_tag_block(tags: dict[str, str], indent: str = " ") -> str:
return "\n".join(f'{indent}{k} = "{v}"' for k, v in tags.items())
def _read_user_data(naming_prefix: str, allowed_ip: str) -> str:
user_data_path = SKILL_ROOT / "templates" / "jumphost-userdata.sh"
text = user_data_path.read_text()
return (
text.replace("{{HOSTNAME}}", f"{naming_prefix}-jumphost-host")
.replace("{{ALLOWED_IP}}", allowed_ip)
)
"""Output formatters: json (default), table, markdown, ids.
All service/intent commands produce a list-of-dicts or a single dict, then
hand it here for printing.
"""
from __future__ import annotations
import json
import sys
from datetime import datetime, date
from typing import Any, Iterable, Optional
try:
from tabulate import tabulate
except ImportError: # pragma: no cover
tabulate = None # type: ignore
def _json_default(obj: Any) -> Any:
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, set):
return sorted(obj)
if hasattr(obj, "__dict__"):
return obj.__dict__
return str(obj)
def emit(
data: Any,
fmt: str = "json",
*,
columns: Optional[list[str]] = None,
id_key: str = "id",
) -> None:
"""Print `data` in the requested format.
fmt:
json — pretty-printed JSON (default; what Claude expects)
table — tabulated rows (terminal-readable)
markdown — github-flavored markdown table
ids — extract one ID per line (good for pipelines)
"""
fmt = (fmt or "json").lower()
if fmt == "json":
print(json.dumps(data, indent=2, default=_json_default, sort_keys=False))
return
rows = _normalize_to_rows(data)
if fmt == "ids":
for row in rows:
value = row.get(id_key) or row.get("id") or row.get("Id") or row.get("name")
if value:
print(value)
return
if not rows:
if fmt == "markdown":
print("_(no rows)_")
else:
print("(no rows)")
return
if columns is None:
# Use keys from the first row, preserving order.
columns = list(rows[0].keys())
table_rows = [[_render_cell(r.get(c)) for c in columns] for r in rows]
if fmt == "table":
if tabulate is None:
# Fallback if tabulate isn't installed yet — fall back to JSON.
print(json.dumps(rows, indent=2, default=_json_default), file=sys.stderr)
print(
"(install `tabulate` for prettier table output: "
"pip install tabulate)",
file=sys.stderr,
)
return
print(tabulate(table_rows, headers=columns, tablefmt="psql"))
return
if fmt == "markdown":
if tabulate is None:
# Hand-roll a tiny markdown table if tabulate isn't there.
print("| " + " | ".join(columns) + " |")
print("| " + " | ".join("---" for _ in columns) + " |")
for row in table_rows:
print("| " + " | ".join(str(c) for c in row) + " |")
return
print(tabulate(table_rows, headers=columns, tablefmt="github"))
return
raise ValueError(f"Unknown output format: {fmt}")
def _normalize_to_rows(data: Any) -> list[dict]:
"""Coerce data into a list-of-dicts. Single dicts become one row."""
if isinstance(data, list):
return [d if isinstance(d, dict) else {"value": d} for d in data]
if isinstance(data, dict):
return [data]
return [{"value": data}]
def _render_cell(value: Any) -> str:
if value is None:
return ""
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, (list, tuple, set)):
return ", ".join(str(v) for v in value)
if isinstance(value, dict):
# Compact representation for nested dicts
return json.dumps(value, default=_json_default, separators=(",", ":"))
return str(value)
def emit_error(message: str, *, exit_code: int = 1) -> None:
"""Print a structured error and exit. Format mirrors other skills' convention."""
print(json.dumps({"error": message}, indent=2))
sys.exit(exit_code)
"""CloudWatch operations: log tailing + simple metric queries."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Optional
import boto3
def list_log_groups(
session: boto3.Session,
*,
name_prefix: Optional[str] = None,
) -> list[dict[str, Any]]:
cw = session.client("logs")
paginator = cw.get_paginator("describe_log_groups")
kwargs: dict[str, Any] = {}
if name_prefix:
kwargs["logGroupNamePrefix"] = name_prefix
out: list[dict[str, Any]] = []
for page in paginator.paginate(**kwargs):
for g in page.get("logGroups", []):
out.append(
{
"name": g.get("logGroupName"),
"stored_bytes": g.get("storedBytes"),
"retention_days": g.get("retentionInDays"),
"created": (
datetime.utcfromtimestamp(g.get("creationTime", 0) / 1000)
if g.get("creationTime")
else None
),
}
)
return out
def tail_logs(
session: boto3.Session,
log_group: str,
*,
since: str = "1h",
limit: int = 500,
filter_pattern: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Tail a log group. since='1h', '30m', '2d' supported."""
cw = session.client("logs")
seconds = _parse_since(since)
start_ms = int((datetime.utcnow() - timedelta(seconds=seconds)).timestamp() * 1000)
out: list[dict[str, Any]] = []
next_token: Optional[str] = None
while True:
kwargs: dict[str, Any] = {
"logGroupName": log_group,
"startTime": start_ms,
"limit": min(10000, limit - len(out)) or 1,
}
if filter_pattern:
kwargs["filterPattern"] = filter_pattern
if next_token:
kwargs["nextToken"] = next_token
resp = cw.filter_log_events(**kwargs)
for ev in resp.get("events", []):
out.append(
{
"timestamp": datetime.utcfromtimestamp(
ev.get("timestamp", 0) / 1000
),
"stream": ev.get("logStreamName"),
"message": ev.get("message", "").rstrip(),
}
)
if len(out) >= limit:
return out
next_token = resp.get("nextToken")
if not next_token:
break
return out
def metric_statistics(
session: boto3.Session,
*,
namespace: str,
metric_name: str,
dimensions: Optional[list[dict[str, str]]] = None,
days: int = 1,
period_seconds: int = 300,
statistics: Optional[list[str]] = None,
) -> list[dict[str, Any]]:
"""Pull GetMetricStatistics datapoints. Statistics defaults to ['Average']."""
cw = session.client("cloudwatch")
end = datetime.utcnow()
start = end - timedelta(days=days)
resp = cw.get_metric_statistics(
Namespace=namespace,
MetricName=metric_name,
Dimensions=dimensions or [],
StartTime=start,
EndTime=end,
Period=period_seconds,
Statistics=statistics or ["Average"],
)
points = sorted(
resp.get("Datapoints", []), key=lambda p: p.get("Timestamp")
)
return [
{
"timestamp": p.get("Timestamp"),
**{
k.lower(): p.get(k)
for k in ("Average", "Sum", "Maximum", "Minimum", "SampleCount")
if k in p
},
"unit": p.get("Unit"),
}
for p in points
]
def _parse_since(s: str) -> int:
if not s:
return 3600
unit = s[-1].lower()
try:
n = int(s[:-1])
except ValueError:
return 3600
return {"s": n, "m": n * 60, "h": n * 3600, "d": n * 86400}.get(unit, 3600)
"""Cost Explorer operations.
Notes on Cost Explorer:
- Endpoint is global; client must be in us-east-1.
- Costs reported in UnblendedCost USD by default.
- Granularity: DAILY (most useful for last-30-days), MONTHLY, HOURLY.
- Tag-based grouping requires the tags to be activated as cost-allocation
tags in the Billing console; otherwise GroupBy on tag returns nothing.
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any, Optional
import boto3
CE_REGION = "us-east-1"
def _ce_client(session: boto3.Session):
return session.client("ce", region_name=CE_REGION)
def _date_window(days: int) -> tuple[str, str]:
"""Cost Explorer expects ISO date strings; end is exclusive."""
end = date.today()
start = end - timedelta(days=days)
return start.isoformat(), end.isoformat()
def total_cost(
session: boto3.Session, *, days: int = 30
) -> dict[str, Any]:
"""Return total UnblendedCost across the window."""
ce = _ce_client(session)
start, end = _date_window(days)
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
)
total = 0.0
unit = "USD"
for r in resp.get("ResultsByTime", []):
amount = r.get("Total", {}).get("UnblendedCost", {})
try:
total += float(amount.get("Amount", 0))
except (TypeError, ValueError):
pass
unit = amount.get("Unit", unit)
return {
"window_days": days,
"start": start,
"end": end,
"total": round(total, 2),
"unit": unit,
}
def by_service(
session: boto3.Session, *, days: int = 30
) -> list[dict[str, Any]]:
"""Cost grouped by AWS service over the window."""
ce = _ce_client(session)
start, end = _date_window(days)
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
)
totals: dict[str, float] = {}
unit = "USD"
for r in resp.get("ResultsByTime", []):
for g in r.get("Groups", []):
keys = g.get("Keys", [])
if not keys:
continue
service = keys[0]
amount = g.get("Metrics", {}).get("UnblendedCost", {})
try:
totals[service] = totals.get(service, 0.0) + float(
amount.get("Amount", 0)
)
except (TypeError, ValueError):
continue
unit = amount.get("Unit", unit)
rows = [
{"service": s, "amount": round(v, 2), "unit": unit}
for s, v in sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
]
return rows
def by_tag(
session: boto3.Session, *, tag_key: str, days: int = 30
) -> list[dict[str, Any]]:
"""Cost grouped by a tag value (e.g. tag_key='Customer').
NOTE: requires the tag to be activated as a cost-allocation tag in the
Billing console — otherwise this returns empty.
"""
ce = _ce_client(session)
start, end = _date_window(days)
resp = ce.get_cost_and_usage(
TimePeriod={"Start": start, "End": end},
Granularity="MONTHLY",
Metrics=["UnblendedCost"],
GroupBy=[{"Type": "TAG", "Key": tag_key}],
)
totals: dict[str, float] = {}
unit = "USD"
for r in resp.get("ResultsByTime", []):
for g in r.get("Groups", []):
keys = g.get("Keys", [])
if not keys:
continue
# Cost Explorer returns "TagKey$TagValue"
raw = keys[0]
value = raw.split("$", 1)[1] if "$" in raw else raw
value = value or "<untagged>"
amount = g.get("Metrics", {}).get("UnblendedCost", {})
try:
totals[value] = totals.get(value, 0.0) + float(amount.get("Amount", 0))
except (TypeError, ValueError):
continue
unit = amount.get("Unit", unit)
return [
{tag_key.lower(): v, "amount": round(amt, 2), "unit": unit}
for v, amt in sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
]
"""ECR operations: repos, images, login helper."""
from __future__ import annotations
import base64
from typing import Any, Optional
import boto3
from ..tagging import tags_from_aws
def list_repos(session: boto3.Session) -> list[dict[str, Any]]:
ecr = session.client("ecr")
paginator = ecr.get_paginator("describe_repositories")
out: list[dict[str, Any]] = []
for page in paginator.paginate():
for r in page.get("repositories", []):
out.append(
{
"name": r.get("repositoryName"),
"arn": r.get("repositoryArn"),
"uri": r.get("repositoryUri"),
"created": r.get("createdAt"),
"image_tag_mutability": r.get("imageTagMutability"),
"scan_on_push": r.get("imageScanningConfiguration", {}).get(
"scanOnPush"
),
"tags": _repo_tags(ecr, r.get("repositoryArn")),
}
)
return out
def list_images(
session: boto3.Session,
repo: str,
*,
limit: int = 50,
) -> list[dict[str, Any]]:
ecr = session.client("ecr")
paginator = ecr.get_paginator("describe_images")
out: list[dict[str, Any]] = []
for page in paginator.paginate(repositoryName=repo):
for img in page.get("imageDetails", []):
out.append(
{
"tags": img.get("imageTags") or [],
"digest": img.get("imageDigest"),
"pushed": img.get("imagePushedAt"),
"size_mb": (
round(img.get("imageSizeInBytes", 0) / (1024 * 1024), 2)
if img.get("imageSizeInBytes")
else None
),
"scan_findings_severity_counts": (
img.get("imageScanFindingsSummary", {}).get(
"findingSeverityCounts"
)
if img.get("imageScanFindingsSummary")
else None
),
}
)
if len(out) >= limit:
return out
return out
def login_command(session: boto3.Session) -> dict[str, Any]:
"""Return a docker login command + token. Token expires in 12 hours."""
ecr = session.client("ecr")
resp = ecr.get_authorization_token()
auth = (resp.get("authorizationData") or [{}])[0]
raw = auth.get("authorizationToken", "")
decoded = base64.b64decode(raw).decode("utf-8") if raw else ""
user, _, password = decoded.partition(":")
endpoint = auth.get("proxyEndpoint", "")
return {
"registry": endpoint,
"username": user,
"expires_at": auth.get("expiresAt"),
"docker_login_command": (
f"echo '{password}' | docker login --username {user} "
f"--password-stdin {endpoint}"
),
}
def _repo_tags(ecr_client, arn: Optional[str]) -> dict[str, str]:
if not arn:
return {}
try:
resp = ecr_client.list_tags_for_resource(resourceArn=arn)
return tags_from_aws(resp.get("tags"))
except Exception:
return {}
"""EKS operations: list clusters, write kubeconfig.
We don't wrap full cluster CRUD — those go through Terraform / eksctl.
"""
from __future__ import annotations
import base64
import os
import subprocess
from pathlib import Path
from typing import Any, Optional
import boto3
import yaml # type: ignore
from ..tagging import tags_from_aws
def list_clusters(
session: boto3.Session,
*,
customer: Optional[str] = None,
) -> list[dict[str, Any]]:
eks = session.client("eks")
out: list[dict[str, Any]] = []
paginator = eks.get_paginator("list_clusters")
for page in paginator.paginate():
for name in page.get("clusters", []):
cluster = eks.describe_cluster(name=name).get("cluster", {})
tags = cluster.get("tags") or {}
if customer and tags.get("Customer") != customer:
continue
out.append(
{
"name": cluster.get("name"),
"status": cluster.get("status"),
"version": cluster.get("version"),
"endpoint": cluster.get("endpoint"),
"platform_version": cluster.get("platformVersion"),
"role_arn": cluster.get("roleArn"),
"created": cluster.get("createdAt"),
"tags": tags,
}
)
return out
def write_kubeconfig(
session: boto3.Session,
cluster_name: str,
*,
kubeconfig_path: Optional[Path] = None,
) -> dict[str, Any]:
"""Write a kubeconfig entry for the cluster.
Tries `aws eks update-kubeconfig` first (the standard way). Falls back
to writing a minimal config directly if the AWS CLI isn't available.
"""
region = session.region_name
profile = session.profile_name
target = Path(kubeconfig_path or os.environ.get("KUBECONFIG") or "~/.kube/config")
target = target.expanduser()
target.parent.mkdir(parents=True, exist_ok=True)
# Prefer aws CLI when present — it's the canonical path.
try:
env = os.environ.copy()
env["AWS_PROFILE"] = profile
rc = subprocess.run(
[
"aws",
"eks",
"update-kubeconfig",
"--name",
cluster_name,
"--region",
region or "us-west-2",
"--profile",
profile,
],
check=False,
capture_output=True,
text=True,
env=env,
)
if rc.returncode == 0:
return {
"cluster": cluster_name,
"kubeconfig_path": str(target),
"method": "aws-cli",
"stdout": rc.stdout.strip(),
}
except FileNotFoundError:
pass
# Fallback: write a minimal kubeconfig directly.
eks = session.client("eks")
cluster = eks.describe_cluster(name=cluster_name).get("cluster", {})
endpoint = cluster.get("endpoint")
ca = cluster.get("certificateAuthority", {}).get("data")
if not (endpoint and ca):
raise RuntimeError(
"Could not retrieve cluster endpoint / CA. Ensure your role has "
"eks:DescribeCluster permission."
)
config = {
"apiVersion": "v1",
"kind": "Config",
"clusters": [
{
"name": cluster_name,
"cluster": {"server": endpoint, "certificate-authority-data": ca},
}
],
"contexts": [
{
"name": cluster_name,
"context": {"cluster": cluster_name, "user": cluster_name},
}
],
"current-context": cluster_name,
"users": [
{
"name": cluster_name,
"user": {
"exec": {
"apiVersion": "client.authentication.k8s.io/v1beta1",
"command": "aws",
"args": [
"--region",
region,
"eks",
"get-token",
"--cluster-name",
cluster_name,
],
"env": [{"name": "AWS_PROFILE", "value": profile}],
}
},
}
],
}
target.write_text(yaml.safe_dump(config, sort_keys=False))
target.chmod(0o600)
return {
"cluster": cluster_name,
"kubeconfig_path": str(target),
"method": "fallback",
"warning": "Wrote a minimal config; aws-cli would have merged with existing.",
}
"""IAM service operations.
Read-only in v1. Writes are deliberately not exposed (IAM mistakes are
high-cost; do those in console or via Terraform).
"""
from __future__ import annotations
from typing import Any
import boto3
from ..auth import whoami as _whoami
def who_am_i(session: boto3.Session) -> dict[str, Any]:
"""STS GetCallerIdentity + account alias if available."""
return _whoami(session)
def list_users(session: boto3.Session) -> list[dict]:
iam = session.client("iam")
out: list[dict] = []
paginator = iam.get_paginator("list_users")
for page in paginator.paginate():
for u in page.get("Users", []):
out.append(
{
"user_name": u.get("UserName"),
"user_id": u.get("UserId"),
"arn": u.get("Arn"),
"created": u.get("CreateDate"),
"password_last_used": u.get("PasswordLastUsed"),
}
)
return out
def list_roles(session: boto3.Session) -> list[dict]:
iam = session.client("iam")
out: list[dict] = []
paginator = iam.get_paginator("list_roles")
for page in paginator.paginate():
for r in page.get("Roles", []):
out.append(
{
"role_name": r.get("RoleName"),
"role_id": r.get("RoleId"),
"arn": r.get("Arn"),
"created": r.get("CreateDate"),
"path": r.get("Path"),
}
)
return out
def list_policies(
session: boto3.Session, *, scope: str = "Local"
) -> list[dict]:
"""List managed policies. scope='Local' = customer-managed (default);
scope='AWS' = AWS-managed; scope='All' = both."""
iam = session.client("iam")
out: list[dict] = []
paginator = iam.get_paginator("list_policies")
for page in paginator.paginate(Scope=scope):
for p in page.get("Policies", []):
out.append(
{
"policy_name": p.get("PolicyName"),
"arn": p.get("Arn"),
"attachment_count": p.get("AttachmentCount"),
"created": p.get("CreateDate"),
"updated": p.get("UpdateDate"),
}
)
return out
boto3>=1.34.0
botocore[crt]>=1.34.0
tabulate>=0.9.0
python-dateutil>=2.8.0
PyYAML>=6.0
{
"_comment": "Copy to ../customers/<name>.json and edit. Gitignored.",
"name": "example-customer",
"region": "us-west-2",
"naming_prefix": "example",
"jumphost": {
"instance_type": "t4g.small",
"allowed_ingress": ["0.0.0.0/0"],
"ssh_key_path": "~/.ssh/aws-skill-example-jumphost"
},
"owner": "idan@zergai.com",
"default_environment": "dev"
}
{
"required_keys": ["Customer", "Project", "Owner", "Environment", "ManagedBy"],
"skill_value": "zerg-aws-skill",
"schema_notes": {
"Customer": "lowercase short name, e.g. dmatrix, cesium, andesite",
"Project": "scope within customer, e.g. jumphost, poc, dev, demo",
"Owner": "email of the human responsible",
"Environment": "one of dev, staging, prod",
"ManagedBy": "always 'zerg-aws-skill' for skill-managed resources"
},
"cost_allocation": "Customer and Project should be activated as cost-allocation tags in AWS Billing for `cost by-tag` to work."
}