augint-org 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ai_org/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """AI-ORG: AWS Organization management CLI with Control Tower integration.
2
+
3
+ This package provides a comprehensive command-line interface for managing AWS Organizations
4
+ with AWS Control Tower. It automates account creation, SSO permission assignment, and
5
+ StackSet deployment monitoring for Control Tower organizations.
6
+
7
+ Key Features:
8
+ - Automated account creation with proper OU placement
9
+ - SSO permission management and assignment
10
+ - StackSet deployment monitoring and status checking
11
+ - Configuration management with caching
12
+ - Enterprise-grade DevOps automation
13
+
14
+ Example:
15
+ Basic usage example::
16
+
17
+ >>> from ai_org.cli import cli
18
+ >>> # Use CLI commands: ai-org account create, ai-org sso assign, etc.
19
+
20
+ Modules:
21
+ cli: Main CLI entry point and command routing
22
+ commands: Command implementations for account, SSO, StackSet, and config management
23
+ core: Core business logic and AWS service managers
24
+ utils: Utility functions for caching, output formatting, and validation
25
+
26
+ Attributes:
27
+ __version__: Package version string
28
+ """
29
+
30
+ from ai_org.__version__ import __version__
31
+
32
+ __all__ = ["__version__"]
ai_org/__version__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Version information for augint-org."""
2
+
3
+ import importlib.metadata
4
+
5
+ try:
6
+ # Get version from installed package metadata
7
+ __version__ = importlib.metadata.version("augint-org")
8
+ except importlib.metadata.PackageNotFoundError:
9
+ # Fallback for development
10
+ __version__ = "0.0.0-dev"
ai_org/cli.py ADDED
@@ -0,0 +1,132 @@
1
+ """Main CLI entry point for AI-ORG AWS Organization management tool.
2
+
3
+ This module defines the primary command-line interface for AI-ORG, including:
4
+ - Command-line argument parsing and validation
5
+ - Global configuration and context management
6
+ - Error handling and user-friendly output formatting
7
+ - Integration with all command modules (account, SSO, StackSet, config)
8
+
9
+ The CLI supports both interactive usage and automation through JSON output modes,
10
+ making it suitable for both manual operations and CI/CD pipeline integration.
11
+
12
+ Example:
13
+ Basic CLI usage::
14
+
15
+ $ ai-org --help
16
+ $ ai-org account create lls-staging lls-staging@company.com --wait
17
+ $ ai-org sso assign 123456789012 --principal jane@company.com
18
+ $ ai-org stackset status 123456789012 --wait
19
+ """
20
+
21
+ import sys
22
+ from typing import Optional
23
+
24
+ import click
25
+ from dotenv import load_dotenv
26
+ from rich.console import Console
27
+
28
+ from ai_org.__version__ import __version__
29
+ from ai_org.commands import account, billing, config, dns, ou, sso, stackset
30
+ from ai_org.utils.output import OutputFormatter
31
+
32
+ # Load .env file from current directory if it exists
33
+ load_dotenv()
34
+
35
+ console = Console()
36
+ output = OutputFormatter()
37
+
38
+
39
+ @click.group(invoke_without_command=True)
40
+ @click.option(
41
+ "--profile",
42
+ envvar="AWS_PROFILE",
43
+ help="AWS profile to use (default: from config or 'org')",
44
+ )
45
+ @click.option(
46
+ "--region",
47
+ envvar="AWS_REGION",
48
+ default="us-east-1",
49
+ help="AWS region (default: from config or 'us-east-1')",
50
+ )
51
+ @click.option("--debug", is_flag=True, help="Enable debug logging")
52
+ @click.option("--json", is_flag=True, help="Output in JSON format for automation")
53
+ @click.version_option(version=__version__, prog_name="ai-org")
54
+ @click.pass_context
55
+ def cli(
56
+ ctx: click.Context,
57
+ profile: Optional[str],
58
+ region: str,
59
+ debug: bool,
60
+ json: bool,
61
+ ) -> None:
62
+ """AI-ORG: Manage AWS Organization accounts with Control Tower.
63
+
64
+ This tool automates AWS account creation, SSO permission assignment,
65
+ and StackSet deployment monitoring for Control Tower organizations.
66
+
67
+ Quick start:
68
+ ai-org config init # Initialize configuration
69
+ ai-org account create # Create new account
70
+ ai-org sso assign # Assign SSO permissions
71
+
72
+ Environment Variables:
73
+ AWS_PROFILE: Default AWS profile name
74
+ AWS_REGION: Default AWS region
75
+ """
76
+ # Store options in context
77
+ ctx.ensure_object(dict)
78
+ ctx.obj["profile"] = profile
79
+ ctx.obj["region"] = region
80
+ ctx.obj["debug"] = debug
81
+ ctx.obj["json"] = json
82
+ ctx.obj["output"] = output
83
+
84
+ # Set output format
85
+ output.set_json_mode(json)
86
+
87
+ # Show help if no command provided
88
+ if ctx.invoked_subcommand is None:
89
+ click.echo(ctx.get_help())
90
+
91
+
92
+ # Add command groups
93
+ cli.add_command(account.account)
94
+ cli.add_command(billing.billing)
95
+ cli.add_command(dns.dns)
96
+ cli.add_command(ou.ou)
97
+ cli.add_command(sso.sso)
98
+ cli.add_command(stackset.stackset)
99
+ cli.add_command(config.config)
100
+
101
+
102
+ def main() -> int:
103
+ """Main entry point for the CLI application.
104
+
105
+ Handles top-level exception catching and returns appropriate exit codes
106
+ for different types of errors encountered during execution.
107
+
108
+ Returns:
109
+ Exit code: 0 for success, 1 for general errors, 130 for keyboard interrupt
110
+
111
+ Raises:
112
+ SystemExit: Always exits with appropriate code based on execution result
113
+ """
114
+ try:
115
+ cli()
116
+ return 0
117
+ except click.ClickException as e:
118
+ e.show()
119
+ return 1
120
+ except KeyboardInterrupt:
121
+ console.print("\n[yellow]Interrupted[/yellow]")
122
+ return 130
123
+ except Exception as e:
124
+ if "--debug" in sys.argv:
125
+ console.print_exception()
126
+ else:
127
+ console.print(f"[red]Error:[/red] {e}")
128
+ return 1
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Command modules for ai-org CLI."""
@@ -0,0 +1,191 @@
1
+ """Account management commands."""
2
+
3
+ from typing import Optional
4
+
5
+ import click
6
+
7
+ from ai_org.core.account_factory import AccountFactory
8
+ from ai_org.core.account_manager import AccountManager
9
+ from ai_org.core.ou_manager import OUManager
10
+
11
+
12
+ @click.group()
13
+ def account() -> None:
14
+ """Manage AWS accounts in the organization."""
15
+
16
+
17
+ @account.command()
18
+ @click.argument("name")
19
+ @click.argument("email")
20
+ def create(name: str, email: str) -> None:
21
+ """Create a new AWS account (NOT IMPLEMENTED).
22
+
23
+ \b
24
+ Arguments:
25
+ NAME Account name (e.g., "Staging LandlineScrubber")
26
+ EMAIL Root email address for the account
27
+ """
28
+ raise click.ClickException(
29
+ "Account creation via CLI is not currently implemented.\n"
30
+ "\nPlease create accounts manually via Control Tower console:\n"
31
+ "1. Go to AWS Control Tower → Account Factory\n"
32
+ "2. Create account with desired name and email\n"
33
+ "3. Wait for provisioning to complete (20-30 mins)\n"
34
+ "4. Use 'ai-org dns delegate <account-name> --prefix <prefix>' for DNS\n"
35
+ "\nExample: ai-org dns delegate 'Staging LandlineScrubber' --prefix lls"
36
+ )
37
+
38
+
39
+ @account.command(name="list")
40
+ @click.option("--ou", help="Filter by OU ID")
41
+ @click.option("--status", default="ACTIVE", help="Filter by status (ACTIVE, SUSPENDED)")
42
+ @click.option("--no-tree", is_flag=True, help="Don't show OU tree structure")
43
+ @click.pass_context
44
+ def list_accounts(ctx: click.Context, ou: Optional[str], status: str, no_tree: bool) -> None:
45
+ """List accounts in the organization.
46
+
47
+ \b
48
+ Examples:
49
+ ai-org account list
50
+ ai-org account list --ou ou-55d0-workloads
51
+ ai-org account list --status SUSPENDED
52
+ ai-org account list --no-tree # Skip OU tree display
53
+ """
54
+ output = ctx.obj["output"]
55
+ manager = AccountManager(
56
+ profile=ctx.obj.get("profile"),
57
+ region=ctx.obj.get("region"),
58
+ )
59
+
60
+ try:
61
+ # Show OU structure first (unless --no-tree or --json)
62
+ if not no_tree and not ctx.obj.get("json"):
63
+ ou_manager = OUManager(
64
+ profile=ctx.obj.get("profile"),
65
+ region=ctx.obj.get("region"),
66
+ )
67
+ try:
68
+ ou_tree = ou_manager.get_ou_tree()
69
+ output.info("Organization Structure:")
70
+ tree_str = ou_manager.format_ou_tree(ou_tree)
71
+ output.text(tree_str)
72
+ output.text("") # Empty line for spacing
73
+ except Exception:
74
+ # If we can't get OU tree, just skip it
75
+ pass
76
+
77
+ # Get accounts with OU information
78
+ accounts = manager.list_accounts_with_ou(ou=ou, status=status)
79
+
80
+ if ctx.obj.get("json"):
81
+ output.json_output(accounts)
82
+ else:
83
+ # Format OU path for display
84
+ for account in accounts:
85
+ # Build OU path
86
+ if account.get("ParentType") == "ROOT":
87
+ account["OU"] = "Root"
88
+ elif account.get("ParentName"):
89
+ account["OU"] = account["ParentName"]
90
+ else:
91
+ account["OU"] = account.get("ParentId", "Unknown")
92
+
93
+ output.table(
94
+ accounts,
95
+ columns=["Id", "Name", "Email", "Status", "OU"],
96
+ title=f"AWS Accounts ({status})",
97
+ )
98
+ except Exception as e:
99
+ output.error(f"Failed to list accounts: {e}")
100
+ raise click.ClickException(str(e)) from e
101
+
102
+
103
+ @account.command()
104
+ @click.argument("account-id")
105
+ @click.pass_context
106
+ def get(ctx: click.Context, account_id: str) -> None:
107
+ """Get details for a specific account.
108
+
109
+ \b
110
+ Arguments:
111
+ ACCOUNT-ID AWS account ID (12 digits)
112
+
113
+ \b
114
+ Example:
115
+ ai-org account get 123456789012
116
+ """
117
+ output = ctx.obj["output"]
118
+ manager = AccountManager(
119
+ profile=ctx.obj.get("profile"),
120
+ region=ctx.obj.get("region"),
121
+ )
122
+
123
+ try:
124
+ account = manager.get_account(account_id)
125
+ if ctx.obj.get("json"):
126
+ output.json_output(account)
127
+ else:
128
+ output.dict_display(account, title=f"Account {account_id}")
129
+ except Exception as e:
130
+ output.error(f"Failed to get account: {e}")
131
+ raise click.ClickException(str(e)) from e
132
+
133
+
134
+ @account.command()
135
+ @click.argument("account-id")
136
+ @click.pass_context
137
+ def enrollment_status(ctx: click.Context, account_id: str) -> None:
138
+ """Check Control Tower enrollment status for an account.
139
+
140
+ \b
141
+ Arguments:
142
+ ACCOUNT-ID AWS account ID (12 digits)
143
+
144
+ \b
145
+ Example:
146
+ ai-org account enrollment-status 123456789012
147
+
148
+ This command checks if an account is enrolled in Control Tower
149
+ and shows the enrollment status and related information.
150
+ """
151
+ output = ctx.obj["output"]
152
+ factory = AccountFactory(
153
+ profile=ctx.obj.get("profile"),
154
+ region=ctx.obj.get("region"),
155
+ )
156
+
157
+ try:
158
+ output.progress(f"Checking enrollment status for account {account_id}...")
159
+ status = factory.get_enrollment_status(account_id)
160
+
161
+ if ctx.obj.get("json"):
162
+ output.json_output(status)
163
+ else:
164
+ # Display enrollment information
165
+ if "error" in status:
166
+ output.error(f"Error: {status['error']}")
167
+ return
168
+
169
+ output.info(f"Account: {status['account_name']} ({status['account_id']})")
170
+ output.info(f"Email: {status['account_email']}")
171
+ output.info(f"Status: {status['account_status']}")
172
+ output.info(f"Parent OU: {status['parent_ou']}")
173
+
174
+ if status["enrolled"]:
175
+ output.success(f"✅ Enrollment Status: {status['enrollment_status']}")
176
+ if "control_tower_stacks" in status:
177
+ output.info(f"Control Tower Stacks: {status['control_tower_stacks']}")
178
+ else:
179
+ output.warning(f"⚠️ Enrollment Status: {status['enrollment_status']}")
180
+ output.info("\nTo enroll this account in Control Tower:")
181
+ output.info("1. Go to the Control Tower console")
182
+ output.info("2. Navigate to Organization > Accounts")
183
+ output.info("3. Find this account and click 'Enroll'")
184
+ output.info("\nOr re-create the account using:")
185
+ output.info(
186
+ f" ai-org account create {status['account_name']} {status['account_email']} --ou <OU_NAME>"
187
+ )
188
+
189
+ except Exception as e:
190
+ output.error(f"Failed to check enrollment status: {e}")
191
+ raise click.ClickException(str(e)) from e
@@ -0,0 +1,238 @@
1
+ """Billing management commands for AWS account cost passthrough via Stripe."""
2
+
3
+ import click
4
+
5
+ from ai_org.core.billing_manager import BillingManager
6
+
7
+
8
+ @click.group()
9
+ def billing() -> None:
10
+ """Manage Stripe billing for AWS accounts."""
11
+
12
+
13
+ @billing.command()
14
+ @click.pass_context
15
+ def setup(ctx: click.Context) -> None:
16
+ """One-time Stripe product setup.
17
+
18
+ This will:
19
+ 1. Create a product in Stripe for AWS Infrastructure Services
20
+ 2. Create a price with monthly billing
21
+ 3. Display the product and price IDs for reference
22
+
23
+ \b
24
+ Example:
25
+ ai-org billing setup
26
+ """
27
+ output = ctx.obj["output"]
28
+ from ai_org.core.aws_client import AWSClient
29
+
30
+ aws_client = AWSClient(
31
+ profile=ctx.obj.get("profile"),
32
+ region=ctx.obj.get("region"),
33
+ )
34
+
35
+ try:
36
+ manager = BillingManager(aws_client)
37
+
38
+ # Check which key we're using
39
+ import os
40
+
41
+ if os.getenv("TEST_STRIPE_SECRET_KEY"):
42
+ output.info("Setting up Stripe products for AWS billing (TEST MODE)...")
43
+ output.warning("Using TEST Stripe key - safe for testing")
44
+ else:
45
+ output.info("Setting up Stripe products for AWS billing...")
46
+ if os.getenv("STRIPE_SECRET_KEY", "").startswith("sk_test_"):
47
+ output.warning("Using TEST Stripe key - safe for testing")
48
+ else:
49
+ output.warning("Using PRODUCTION Stripe key - real charges will occur")
50
+
51
+ result = manager.setup_stripe_products()
52
+
53
+ output.success("\nStripe products created successfully!")
54
+ output.info("\nProduct Details:")
55
+ output.info(f" Product ID: {result['product_id']}")
56
+ output.info(f" Price ID: {result['price_id']}")
57
+ output.info("\nNext steps:")
58
+ output.info(" 1. Enable EventBridge integration in Stripe Dashboard")
59
+ output.info(f" 2. AWS Account ID for EventBridge: {aws_client.account_id}")
60
+ output.info(" 3. Select region: us-east-1")
61
+ output.info(" 4. Link AWS accounts to Stripe customers using 'ai-org billing link'")
62
+
63
+ if ctx.obj.get("json_output"):
64
+ output.print_json(result)
65
+
66
+ except Exception as e:
67
+ output.error(f"Failed to setup Stripe products: {e}")
68
+ ctx.exit(1)
69
+
70
+
71
+ @billing.command()
72
+ @click.argument("account_id")
73
+ @click.argument("stripe_customer_id")
74
+ @click.pass_context
75
+ def link(ctx: click.Context, account_id: str, stripe_customer_id: str) -> None:
76
+ """Link AWS account to Stripe customer.
77
+
78
+ \b
79
+ Arguments:
80
+ ACCOUNT_ID AWS account ID to link
81
+ STRIPE_CUSTOMER_ID Stripe customer ID (e.g., cus_xxx)
82
+
83
+ \b
84
+ Example:
85
+ ai-org billing link 123456789012 cus_AbCdEfGhIjKl
86
+
87
+ This creates a mapping in DynamoDB that the Lambda function will use
88
+ to calculate and bill AWS costs for this account to the Stripe customer.
89
+ """
90
+ output = ctx.obj["output"]
91
+ from ai_org.core.aws_client import AWSClient
92
+
93
+ aws_client = AWSClient(
94
+ profile=ctx.obj.get("profile"),
95
+ region=ctx.obj.get("region"),
96
+ )
97
+
98
+ try:
99
+ manager = BillingManager(aws_client)
100
+
101
+ # Validate account ID format
102
+ if not account_id.isdigit() or len(account_id) != 12:
103
+ output.error(f"Invalid AWS account ID format: {account_id}")
104
+ output.info("Account ID must be 12 digits")
105
+ ctx.exit(1)
106
+
107
+ # Validate Stripe customer ID format
108
+ if not stripe_customer_id.startswith("cus_"):
109
+ output.error(f"Invalid Stripe customer ID format: {stripe_customer_id}")
110
+ output.info("Stripe customer ID should start with 'cus_'")
111
+ ctx.exit(1)
112
+
113
+ output.info(f"Linking AWS account {account_id} to Stripe customer {stripe_customer_id}...")
114
+
115
+ manager.link_account(account_id, stripe_customer_id)
116
+
117
+ output.success(
118
+ f"\nSuccessfully linked account {account_id} to customer {stripe_customer_id}"
119
+ )
120
+ output.info("\nBilling will begin with the next invoice cycle.")
121
+
122
+ if ctx.obj.get("json_output"):
123
+ output.print_json(
124
+ {
125
+ "account_id": account_id,
126
+ "stripe_customer_id": stripe_customer_id,
127
+ "status": "linked",
128
+ }
129
+ )
130
+
131
+ except Exception as e:
132
+ output.error(f"Failed to link account: {e}")
133
+ ctx.exit(1)
134
+
135
+
136
+ @billing.command("list")
137
+ @click.pass_context
138
+ def list_mappings(ctx: click.Context) -> None:
139
+ """List all AWS account to Stripe customer mappings.
140
+
141
+ \b
142
+ Example:
143
+ ai-org billing list
144
+
145
+ Shows all configured billing relationships between AWS accounts
146
+ and Stripe customers.
147
+ """
148
+ output = ctx.obj["output"]
149
+ from ai_org.core.aws_client import AWSClient
150
+
151
+ aws_client = AWSClient(
152
+ profile=ctx.obj.get("profile"),
153
+ region=ctx.obj.get("region"),
154
+ )
155
+
156
+ try:
157
+ manager = BillingManager(aws_client)
158
+ output.info("Fetching billing mappings...")
159
+
160
+ mappings = manager.list_mappings()
161
+
162
+ if not mappings:
163
+ output.info("\nNo billing mappings configured.")
164
+ output.info("Use 'ai-org billing link' to add mappings.")
165
+ else:
166
+ output.info(f"\nFound {len(mappings)} billing mapping(s):\n")
167
+
168
+ if ctx.obj.get("json_output"):
169
+ output.json_output(mappings)
170
+ else:
171
+ # Format for table display
172
+ table_data = []
173
+ for mapping in mappings:
174
+ table_data.append(
175
+ {
176
+ "Stripe Customer": mapping["stripe_customer_id"],
177
+ "AWS Accounts": ", ".join(mapping.get("aws_accounts", [])),
178
+ "Created": mapping.get("created_at", "Unknown"),
179
+ }
180
+ )
181
+
182
+ output.table(
183
+ table_data,
184
+ columns=["Stripe Customer", "AWS Accounts", "Created"],
185
+ title="Billing Mappings",
186
+ )
187
+
188
+ except Exception as e:
189
+ output.error(f"Failed to list mappings: {e}")
190
+ ctx.exit(1)
191
+
192
+
193
+ @billing.command()
194
+ @click.argument("account_id")
195
+ @click.pass_context
196
+ def unlink(ctx: click.Context, account_id: str) -> None:
197
+ """Remove AWS account from billing.
198
+
199
+ \b
200
+ Arguments:
201
+ ACCOUNT_ID AWS account ID to unlink
202
+
203
+ \b
204
+ Example:
205
+ ai-org billing unlink 123456789012
206
+
207
+ This removes the account from billing. The account will not be
208
+ included in future invoices.
209
+ """
210
+ output = ctx.obj["output"]
211
+ from ai_org.core.aws_client import AWSClient
212
+
213
+ aws_client = AWSClient(
214
+ profile=ctx.obj.get("profile"),
215
+ region=ctx.obj.get("region"),
216
+ )
217
+
218
+ try:
219
+ manager = BillingManager(aws_client)
220
+
221
+ # Validate account ID format
222
+ if not account_id.isdigit() or len(account_id) != 12:
223
+ output.error(f"Invalid AWS account ID format: {account_id}")
224
+ output.info("Account ID must be 12 digits")
225
+ ctx.exit(1)
226
+
227
+ output.info(f"Unlinking AWS account {account_id} from billing...")
228
+
229
+ manager.unlink_account(account_id)
230
+
231
+ output.success(f"\nSuccessfully unlinked account {account_id} from billing")
232
+
233
+ if ctx.obj.get("json_output"):
234
+ output.print_json({"account_id": account_id, "status": "unlinked"})
235
+
236
+ except Exception as e:
237
+ output.error(f"Failed to unlink account: {e}")
238
+ ctx.exit(1)