Skip to content

Commit 127ad60

Browse files
authored
Merge pull request #12 from mtpontes/release/2.0.1
fix: default command and config proxy
2 parents 0fa1fe1 + 77a0134 commit 127ad60

52 files changed

Lines changed: 1691 additions & 128 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pyproject.toml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ clean = [
4040
"rm -rf dist build *.egg-info"
4141
]
4242

43+
[tool.hatch.envs.test]
44+
dependencies = [
45+
"pytest",
46+
"pytest-cov",
47+
"testcontainers[localstack]",
48+
"moto[s3,athena,sts]",
49+
"requests",
50+
]
51+
52+
[tool.hatch.envs.test.scripts]
53+
run = "pytest {args:tests}"
54+
cov = "pytest --cov=src --cov-report=term-missing {args:tests}"
55+
4356
[tool.hatch.build.targets.wheel]
4457
packages = ["src"]
4558

@@ -60,3 +73,8 @@ select = ["E", "W", "F"]
6073
[tool.ruff.lint.isort]
6174
force-wrap-aliases = true
6275
split-on-trailing-comma = true
76+
77+
[tool.pytest.ini_options]
78+
filterwarnings = [
79+
"ignore:The wait_for_logs function with string or callable predicates is deprecated:DeprecationWarning:testcontainers.localstack",
80+
]

src/cli/cli.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
compare_entry,
6464
sync_entry,
6565
git_hook_entry,
66+
dashboard_entry,
6667
)
6768

6869

@@ -71,6 +72,7 @@
7172
name="workstate",
7273
help="Portable development environment management tool",
7374
add_completion=False,
75+
no_args_is_help=True,
7476
context_settings={"help_option_names": ["-h", "--help"]},
7577
)
7678

@@ -123,3 +125,4 @@ def global_callback(
123125
compare_entry.register(app, console, state_service, file_service)
124126
sync_entry.register(app, console, state_service, file_service)
125127
git_hook_entry.register(app, console, hook_svc)
128+
dashboard_entry.register(app, console)

src/cli/dashboard_entry.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import typer
2+
from rich.console import Console
3+
from pathlib import Path
4+
from src.services import state_service, dashboard_service
5+
from src.utils.utils import handle_error
6+
from src.services.config_service import ConfigService
7+
8+
def register(app: typer, console: Console):
9+
@app.command("dashboard", help="Generates and uploads an S3 dashboard HTML")
10+
def dashboard(
11+
open_browser: bool = typer.Option(True, "--open", help="Open the dashboard in browser after upload"),
12+
) -> None:
13+
"""Generates a standalone HTML dashboard of your S3 bucket states and uploads it.
14+
15+
The dashboard includes:
16+
- Visualization of all project states
17+
- Total disk usage statistics
18+
- Search and filtering capabilities
19+
"""
20+
try:
21+
with console.status("[bold green]Fetching bucket data...", spinner="dots"):
22+
states = state_service.list_states(global_scan=True, use_cache=False)
23+
credentials = ConfigService.get_aws_credentials()
24+
bucket_name = credentials.bucket_name
25+
26+
if not states:
27+
console.print("[yellow]No states found in bucket to generate dashboard.[/yellow]")
28+
return
29+
30+
with console.status("[bold blue]Generating HTML Dashboard...", spinner="dots"):
31+
html_content = dashboard_service.DashboardService.generate_dashboard_html(states, bucket_name)
32+
33+
# Write to temp file
34+
from tempfile import NamedTemporaryFile
35+
with NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as tmp:
36+
tmp.write(html_content)
37+
tmp_path = Path(tmp.name)
38+
39+
with console.status("[bold magenta]Uploading to S3...", spinner="dots"):
40+
from src.clients import s3_client
41+
s3 = s3_client.create_s3_resource()
42+
# Upload to index.html at root (or a dedicated reports/ folder)
43+
target_key = "dashboard.html"
44+
s3.upload_file(str(tmp_path), target_key, ExtraArgs={'ContentType': 'text/html'})
45+
46+
# Generate a pre-signed URL for temporary access
47+
s3_client_raw = s3_client.create_s3_client()
48+
url = s3_client_raw.generate_presigned_url(
49+
'get_object',
50+
Params={'Bucket': bucket_name, 'Key': target_key},
51+
ExpiresIn=3600 # 1 hour
52+
)
53+
54+
console.print(f"\n[green][OK] Dashboard generated and uploaded to {bucket_name}/{target_key}[/green]")
55+
console.print(f"[bold cyan]Access URL (Valid for 1 hour):[/bold cyan]\n{url}\n")
56+
57+
if open_browser:
58+
import webbrowser
59+
webbrowser.open(url)
60+
61+
# Cleanup
62+
tmp_path.unlink()
63+
64+
except Exception as e:
65+
handle_error(console, e)

src/cli/download_entry.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ def download_state(
1919
yes_to_hooks: bool = typer.Option(
2020
False, "--yes-to-hooks", "-y", help="Execute post-restore hooks without confirmation"
2121
),
22+
path_filters: list[str] = typer.Option(
23+
None, "--path", help="Specific files or directories to restore (glob patterns supported)"
24+
),
2225
) -> None:
2326
"""Restores a saved project state from AWS S3
2427
@@ -57,6 +60,7 @@ def download_state(
5760
state_service=state_service,
5861
hook_service=hook_service,
5962
yes_to_hooks=yes_to_hooks,
63+
path_filters=path_filters,
6064
).execute()
6165
except Exception as e:
6266
handle_error(console, e)

src/cli/list_entry.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ def list_state_zips(
1717
interactive: bool = typer.Option(
1818
False, "--interactive", "-i", help="Interactive mode with fuzzy search"
1919
),
20+
use_cache: bool = typer.Option(True, "--cache/--no-cache", help="Use local metadata cache (default: True)"),
2021
) -> None:
2122
"""Lists all project states available in AWS S3
2223
@@ -50,7 +51,8 @@ def list_state_zips(
5051
interactive=interactive,
5152
system_filter=system,
5253
branch_filter=branch,
53-
older_than_filter=older_than
54+
older_than_filter=older_than,
55+
use_cache=use_cache
5456
).execute()
5557
except Exception as e:
5658
handle_error(console, e)

src/clients/s3_client.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,121 @@
55

66
from src.services.config_service import ConfigService
77
from src.model.dto.aws_credentials_dto import AWSCredentialsDTO
8+
from src.exception.credentials_validation_exception import CredentialsValidationException
9+
10+
11+
def validate_credentials(require_bucket: bool = True):
12+
"""Public validation of credentials before creating S3/STS clients."""
13+
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
14+
errors = {}
15+
if not credentials.access_key_id:
16+
errors["access_key_id"] = "Access Key ID is missing"
17+
if not credentials.secret_access_key:
18+
errors["secret_access_key"] = "Secret Access Key is missing"
19+
if require_bucket and not credentials.bucket_name:
20+
errors["bucket_name"] = "S3 Bucket Name is missing"
21+
22+
if errors:
23+
raise CredentialsValidationException(errors)
824

925

1026
def create_s3_resource() -> Bucket:
1127
"""Creates S3 resource with proper configuration"""
1228
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
29+
validate_credentials()
30+
1331
s3_resource = boto3.resource(
1432
"s3",
1533
aws_access_key_id=credentials.access_key_id,
1634
aws_secret_access_key=credentials.secret_access_key,
1735
region_name=credentials.region,
36+
endpoint_url=getattr(credentials, "endpoint_url", None),
1837
)
1938
return s3_resource.Bucket(credentials.bucket_name)
2039

2140

2241
def create_s3_client() -> S3Client:
2342
"""Creates S3 resource with proper configuration"""
2443
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
44+
validate_credentials()
45+
2546
return boto3.client(
2647
"s3",
2748
aws_access_key_id=credentials.access_key_id,
2849
aws_secret_access_key=credentials.secret_access_key,
2950
region_name=credentials.region,
51+
endpoint_url=getattr(credentials, "endpoint_url", None),
3052
)
3153

3254

3355
def create_sts_client():
3456
"""Creates STS client for identity verification"""
3557
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
58+
validate_credentials(require_bucket=False)
59+
3660
return boto3.client(
3761
"sts",
3862
aws_access_key_id=credentials.access_key_id,
3963
aws_secret_access_key=credentials.secret_access_key,
4064
region_name=credentials.region,
4165
)
66+
67+
68+
def create_bucket(bucket_name: str, region: str) -> None:
69+
"""Creates an S3 bucket with the specified name and region."""
70+
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
71+
client = boto3.client(
72+
"s3",
73+
aws_access_key_id=credentials.access_key_id,
74+
aws_secret_access_key=credentials.secret_access_key,
75+
region_name=region,
76+
endpoint_url=getattr(credentials, "endpoint_url", None),
77+
)
78+
79+
if region == "us-east-1":
80+
client.create_bucket(Bucket=bucket_name)
81+
else:
82+
client.create_bucket(
83+
Bucket=bucket_name,
84+
CreateBucketConfiguration={"LocationConstraint": region},
85+
)
86+
87+
88+
def put_public_access_block(bucket_name: str, region: str) -> None:
89+
"""Configures public access block for the bucket."""
90+
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
91+
client = boto3.client(
92+
"s3",
93+
aws_access_key_id=credentials.access_key_id,
94+
aws_secret_access_key=credentials.secret_access_key,
95+
region_name=region,
96+
endpoint_url=getattr(credentials, "endpoint_url", None),
97+
)
98+
client.put_public_access_block(
99+
Bucket=bucket_name,
100+
PublicAccessBlockConfiguration={
101+
"BlockPublicAcls": True,
102+
"IgnorePublicAcls": True,
103+
"BlockPublicPolicy": True,
104+
"RestrictPublicBuckets": True,
105+
},
106+
)
107+
108+
109+
def list_workstate_buckets() -> list[str]:
110+
"""Lists all S3 buckets that match the Workstate prefix."""
111+
credentials: AWSCredentialsDTO = ConfigService.get_aws_credentials()
112+
client = boto3.client(
113+
"s3",
114+
aws_access_key_id=credentials.access_key_id,
115+
aws_secret_access_key=credentials.secret_access_key,
116+
region_name=credentials.region,
117+
endpoint_url=getattr(credentials, "endpoint_url", None),
118+
)
119+
120+
try:
121+
response = client.list_buckets()
122+
buckets = [b["Name"] for b in response.get("Buckets", [])]
123+
return [b for b in buckets if b.startswith("workstate-storage-")]
124+
except Exception:
125+
return []

src/commands/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33

44
class CommandI(ABC):
55
@abstractmethod
6-
def execute() -> None:
6+
def execute(self) -> None:
77
pass

src/commands/compare_command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def execute(self) -> None:
5050

5151
diff_found = any(res["status"] != "EQUAL" for res in results)
5252
if not diff_found:
53-
self.console.print(f"\n[bold green] Local project matches {self.state_name} perfectly.[/bold green]")
53+
self.console.print(f"\n[bold green][OK] Local project matches {self.state_name} perfectly.[/bold green]")
5454
return
5555

5656
table = compare_view.compare_results_table(self.state_name, results)

src/commands/configure_command.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,52 @@ def execute(self) -> None:
5353

5454
if self.interactive:
5555
credentials: AWSCredentialsDTO = self.prompter.prompt(self.credentials)
56+
else:
57+
credentials: AWSCredentialsDTO = self.credentials
5658

5759
self._save_credentials(credentials)
5860

61+
# Check if bucket exists and offer to create it
62+
if self.interactive:
63+
self._check_and_create_bucket(credentials)
64+
65+
# Add to history for future configuration ease
66+
ConfigService.add_to_bucket_history(credentials.bucket_name)
67+
5968
self.console.print(f"Configuration saved to: {ConfigService.CONFIG_FILE}")
6069
self.console.print("[green]✓ AWS credentials configured successfully![/green]\n")
6170

71+
def _check_and_create_bucket(self, credentials: AWSCredentialsDTO) -> None:
72+
from src.clients import s3_client
73+
from botocore.exceptions import ClientError
74+
75+
bucket_name = credentials.bucket_name
76+
region = credentials.region
77+
78+
try:
79+
# Check if bucket exists and is accessible
80+
# Note: We must create a client with THE NEW credentials just provided
81+
# S3resource factory uses ConfigService which we just updated
82+
s3_resource = s3_client.create_s3_resource()
83+
s3_resource.meta.client.head_bucket(Bucket=bucket_name)
84+
self.console.print(f"[green][OK] Bucket '{bucket_name}' already exists and is accessible.[/green]")
85+
except ClientError as e:
86+
error_code = e.response.get("Error", {}).get("Code")
87+
# 404 means not found, 403 means forbidden (might exist but no access)
88+
if error_code == "404":
89+
self.console.print(f"\n[yellow]⚠ Bucket '{bucket_name}' does not exist.[/yellow]")
90+
if typer.confirm(f"Would you like to create bucket '{bucket_name}' in region '{region}'?", default=True):
91+
with self.console.status(f"[bold green]Creating bucket {bucket_name}...", spinner="dots"):
92+
s3_client.create_bucket(bucket_name, region)
93+
s3_client.put_public_access_block(bucket_name, region)
94+
self.console.print(f"[green][OK] Bucket '{bucket_name}' created successfully with public access blocked.[/green]")
95+
elif error_code == "403":
96+
self.console.print(f"[red]✖ Access denied to bucket '{bucket_name}'. Please check your permissions.[/red]")
97+
else:
98+
self.console.print(f"[red]✖ Error checking bucket: {str(e)}[/red]")
99+
except Exception as e:
100+
self.console.print(f"[red]✖ Unexpected error while checking bucket: {str(e)}[/red]")
101+
62102
def _save_credentials(self, credentials: AWSCredentialsDTO) -> None:
63103
validated_credentials: AWSCredentials = None
64104
try:

src/commands/delete_command.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from src.services import state_service
44
from src.commands.command import CommandI
55
from src.prompts.zip_file_selector_prompter import ZipFileSelectorPrompter
6+
from src.clients import s3_client
67

78

89
class DeleteCommandImpl(CommandI):
@@ -17,6 +18,7 @@ def __init__(
1718
self.state_service = state_service
1819

1920
def execute(self) -> None:
21+
s3_client.validate_credentials()
2022
selected_zip_file: str = self.prompter.prompt(message="Select a zip file to delete:")
2123

2224
with self.console.status("[bold green]Deleting state...", spinner="dots"):

0 commit comments

Comments
 (0)