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 +32 -0
- ai_org/__version__.py +10 -0
- ai_org/cli.py +132 -0
- ai_org/commands/__init__.py +1 -0
- ai_org/commands/account.py +191 -0
- ai_org/commands/billing.py +238 -0
- ai_org/commands/config.py +209 -0
- ai_org/commands/dns.py +463 -0
- ai_org/commands/ou.py +163 -0
- ai_org/commands/sso.py +212 -0
- ai_org/commands/stackset.py +531 -0
- ai_org/core/__init__.py +1 -0
- ai_org/core/account_factory.py +295 -0
- ai_org/core/account_manager.py +327 -0
- ai_org/core/aws_client.py +129 -0
- ai_org/core/billing_manager.py +245 -0
- ai_org/core/config_manager.py +199 -0
- ai_org/core/ou_manager.py +210 -0
- ai_org/core/sso_manager.py +466 -0
- ai_org/core/stackset_manager.py +587 -0
- ai_org/utils/__init__.py +1 -0
- ai_org/utils/cache.py +161 -0
- ai_org/utils/config_loader.py +81 -0
- ai_org/utils/output.py +159 -0
- ai_org/utils/validators.py +146 -0
- augint_org-0.1.0.dist-info/METADATA +1025 -0
- augint_org-0.1.0.dist-info/RECORD +30 -0
- augint_org-0.1.0.dist-info/WHEEL +4 -0
- augint_org-0.1.0.dist-info/entry_points.txt +2 -0
- augint_org-0.1.0.dist-info/licenses/LICENSE +679 -0
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)
|