superset-showtime 0.6.4__py3-none-any.whl → 0.6.7__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.
Potentially problematic release.
This version of superset-showtime might be problematic. Click here for more details.
- showtime/__init__.py +1 -1
- showtime/cli.py +115 -62
- showtime/core/aws.py +38 -54
- showtime/core/constants.py +10 -0
- showtime/core/date_utils.py +80 -0
- showtime/core/git_validation.py +21 -12
- showtime/core/github.py +19 -0
- showtime/core/github_messages.py +3 -1
- showtime/core/pull_request.py +307 -39
- showtime/core/service_name.py +104 -0
- showtime/core/show.py +30 -17
- showtime/core/sync_state.py +137 -0
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/METADATA +1 -1
- superset_showtime-0.6.7.dist-info/RECORD +21 -0
- superset_showtime-0.6.4.dist-info/RECORD +0 -17
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/WHEEL +0 -0
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.7.dist-info}/entry_points.txt +0 -0
showtime/__init__.py
CHANGED
showtime/cli.py
CHANGED
|
@@ -8,9 +8,9 @@ from typing import Dict, Optional
|
|
|
8
8
|
|
|
9
9
|
import typer
|
|
10
10
|
from rich.console import Console
|
|
11
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
11
12
|
from rich.table import Table
|
|
12
13
|
|
|
13
|
-
from .core.emojis import STATUS_DISPLAY
|
|
14
14
|
from .core.github import GitHubError, GitHubInterface
|
|
15
15
|
from .core.github_messages import (
|
|
16
16
|
get_aws_console_urls,
|
|
@@ -73,10 +73,10 @@ def _get_github_workflow_url() -> str:
|
|
|
73
73
|
|
|
74
74
|
|
|
75
75
|
def _get_github_actor() -> str:
|
|
76
|
-
"""Get current GitHub actor with fallback"""
|
|
77
|
-
import
|
|
76
|
+
"""Get current GitHub actor with fallback (DEPRECATED: Use GitHubInterface.get_current_actor())"""
|
|
77
|
+
from .core.github import GitHubInterface
|
|
78
78
|
|
|
79
|
-
return
|
|
79
|
+
return GitHubInterface.get_current_actor()
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
def _get_showtime_footer() -> str:
|
|
@@ -96,7 +96,7 @@ def version() -> None:
|
|
|
96
96
|
def start(
|
|
97
97
|
pr_number: int = typer.Argument(..., help="PR number to create environment for"),
|
|
98
98
|
sha: Optional[str] = typer.Option(None, "--sha", help="Specific commit SHA (default: latest)"),
|
|
99
|
-
ttl: Optional[str] = typer.Option("
|
|
99
|
+
ttl: Optional[str] = typer.Option("48h", help="Time to live (24h, 48h, 1w, close)"),
|
|
100
100
|
size: Optional[str] = typer.Option("standard", help="Environment size (standard, large)"),
|
|
101
101
|
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be done"),
|
|
102
102
|
dry_run_aws: bool = typer.Option(
|
|
@@ -173,7 +173,15 @@ def status(
|
|
|
173
173
|
) -> None:
|
|
174
174
|
"""Show environment status for PR"""
|
|
175
175
|
try:
|
|
176
|
-
|
|
176
|
+
# Show spinner while fetching PR status
|
|
177
|
+
with Progress(
|
|
178
|
+
SpinnerColumn(),
|
|
179
|
+
TextColumn("[progress.description]{task.description}"),
|
|
180
|
+
console=Console(),
|
|
181
|
+
transient=True,
|
|
182
|
+
) as progress:
|
|
183
|
+
progress.add_task(description=f"🎪 Fetching status for PR #{pr_number}...", total=None)
|
|
184
|
+
pr = PullRequest.from_id(pr_number)
|
|
177
185
|
|
|
178
186
|
# Use PullRequest method for data
|
|
179
187
|
status_data = pr.get_status()
|
|
@@ -184,20 +192,22 @@ def status(
|
|
|
184
192
|
|
|
185
193
|
show_data = status_data["show"]
|
|
186
194
|
|
|
187
|
-
# Create status table
|
|
188
|
-
|
|
195
|
+
# Create status table with clickable PR link
|
|
196
|
+
pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
|
|
197
|
+
table = Table(title=f"🎪 Environment Status - [link={pr_url}]PR #{pr_number}[/link]")
|
|
189
198
|
table.add_column("Property", style="cyan")
|
|
190
199
|
table.add_column("Value", style="white")
|
|
191
200
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
201
|
+
from .core.emojis import STATUS_DISPLAY
|
|
202
|
+
|
|
203
|
+
status_display = STATUS_DISPLAY.get(show_data["status"], "❓")
|
|
204
|
+
table.add_row("Status", f"{status_display} {show_data['status'].title()}")
|
|
196
205
|
table.add_row("Environment", f"`{show_data['sha']}`")
|
|
197
|
-
table.add_row("
|
|
206
|
+
table.add_row("Service Name", f"`{show_data['aws_service_name']}`")
|
|
198
207
|
|
|
199
208
|
if show_data["ip"]:
|
|
200
|
-
|
|
209
|
+
superset_url = f"http://{show_data['ip']}:8080"
|
|
210
|
+
table.add_row("Superset URL", f"[link={superset_url}]{superset_url}[/link]")
|
|
201
211
|
if show_data["created_at"]:
|
|
202
212
|
table.add_row("Created", show_data["created_at"])
|
|
203
213
|
|
|
@@ -206,6 +216,13 @@ def status(
|
|
|
206
216
|
if show_data["requested_by"]:
|
|
207
217
|
table.add_row("Requested by", f"@{show_data['requested_by']}")
|
|
208
218
|
|
|
219
|
+
# Add AWS Console URLs - clickable
|
|
220
|
+
from .core.github_messages import get_aws_console_urls
|
|
221
|
+
|
|
222
|
+
aws_urls = get_aws_console_urls(show_data["aws_service_name"])
|
|
223
|
+
table.add_row("AWS Logs", f"[link={aws_urls['logs']}]View Logs ↗[/link]")
|
|
224
|
+
table.add_row("AWS Service", f"[link={aws_urls['service']}]View Service ↗[/link]")
|
|
225
|
+
|
|
209
226
|
# Show active triggers
|
|
210
227
|
trigger_labels = [label for label in pr.labels if "showtime-trigger-" in label]
|
|
211
228
|
if trigger_labels:
|
|
@@ -290,8 +307,16 @@ def list(
|
|
|
290
307
|
) -> None:
|
|
291
308
|
"""List all environments"""
|
|
292
309
|
try:
|
|
293
|
-
#
|
|
294
|
-
|
|
310
|
+
# Show spinner while fetching environments
|
|
311
|
+
with Progress(
|
|
312
|
+
SpinnerColumn(),
|
|
313
|
+
TextColumn("[progress.description]{task.description}"),
|
|
314
|
+
console=Console(),
|
|
315
|
+
transient=True,
|
|
316
|
+
) as progress:
|
|
317
|
+
progress.add_task(description="🎪 Fetching environments...", total=None)
|
|
318
|
+
# Use PullRequest method for data collection
|
|
319
|
+
all_environments = PullRequest.list_all_environments()
|
|
295
320
|
|
|
296
321
|
if not all_environments:
|
|
297
322
|
p("🎪 No environments currently running")
|
|
@@ -319,19 +344,19 @@ def list(
|
|
|
319
344
|
# Create table with full terminal width
|
|
320
345
|
table = Table(title="🎪 Environment List", expand=True)
|
|
321
346
|
table.add_column("PR", style="cyan", min_width=6)
|
|
322
|
-
table.add_column("
|
|
323
|
-
table.add_column("
|
|
347
|
+
table.add_column("Service Name", style="white", min_width=20)
|
|
348
|
+
table.add_column("", style="white", min_width=2) # Emoji type column
|
|
349
|
+
table.add_column("🟢", style="white", min_width=2)
|
|
324
350
|
table.add_column("SHA", style="green", min_width=11)
|
|
325
|
-
table.add_column("
|
|
351
|
+
table.add_column("Age", style="dim white", min_width=12)
|
|
326
352
|
table.add_column("Superset URL", style="blue", min_width=25)
|
|
327
|
-
table.add_column("
|
|
353
|
+
table.add_column("Logs", style="dim blue", min_width=10)
|
|
328
354
|
table.add_column("TTL", style="yellow", min_width=6)
|
|
329
355
|
table.add_column("User", style="magenta", min_width=10)
|
|
330
356
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
type_priority = {"active": 1, "building": 2, "orphaned": 3}
|
|
357
|
+
# Sort by PR number, then by show type (active first, then building, then orphaned, legacy last)
|
|
358
|
+
# TODO: Remove after legacy cleanup - legacy type priority
|
|
359
|
+
type_priority = {"active": 1, "building": 2, "orphaned": 3, "legacy": 4}
|
|
335
360
|
sorted_envs = sorted(
|
|
336
361
|
filtered_envs,
|
|
337
362
|
key=lambda e: (
|
|
@@ -344,14 +369,16 @@ def list(
|
|
|
344
369
|
show_data = env["show"]
|
|
345
370
|
pr_number = env["pr_number"]
|
|
346
371
|
|
|
347
|
-
# Show type with
|
|
372
|
+
# Show type with emoji indicators
|
|
348
373
|
show_type = show_data.get("show_type", "orphaned")
|
|
349
374
|
if show_type == "active":
|
|
350
|
-
type_display = "
|
|
375
|
+
type_display = "🎯" # Active environment (has pointer)
|
|
351
376
|
elif show_type == "building":
|
|
352
|
-
type_display = "#
|
|
377
|
+
type_display = "🔨" # Building environment (hammer is single-width)
|
|
378
|
+
elif show_type == "legacy": # TODO: Remove after legacy cleanup
|
|
379
|
+
type_display = "⚠️" # Legacy environment (no SHA in service name)
|
|
353
380
|
else:
|
|
354
|
-
type_display = "
|
|
381
|
+
type_display = "👻" # Orphaned environment (no pointer)
|
|
355
382
|
|
|
356
383
|
# Make Superset URL clickable and show full URL
|
|
357
384
|
if show_data["ip"]:
|
|
@@ -370,26 +397,32 @@ def list(
|
|
|
370
397
|
pr_url = f"https://github.com/apache/superset/pull/{pr_number}"
|
|
371
398
|
clickable_pr = f"[link={pr_url}]{pr_number}[/link]"
|
|
372
399
|
|
|
373
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
400
|
+
# Get age display from show
|
|
401
|
+
age_display = show_data.get("age", "-")
|
|
402
|
+
|
|
403
|
+
# Simple status display - just emoji
|
|
404
|
+
status_display = "🟢" if show_data["status"] == "running" else "❌"
|
|
405
|
+
|
|
406
|
+
# TODO: Remove after legacy cleanup - handle missing legacy fields
|
|
407
|
+
sha_display = show_data["sha"] if show_data["sha"] != "-" else "-"
|
|
408
|
+
ttl_display = show_data["ttl"] if show_data["ttl"] else "-"
|
|
409
|
+
user_display = (
|
|
410
|
+
f"@{show_data['requested_by']}"
|
|
411
|
+
if show_data["requested_by"] and show_data["requested_by"] != "-"
|
|
412
|
+
else "-"
|
|
413
|
+
)
|
|
382
414
|
|
|
383
415
|
table.add_row(
|
|
384
416
|
clickable_pr,
|
|
417
|
+
f"{show_data['aws_service_name']}-service",
|
|
385
418
|
type_display,
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
419
|
+
status_display,
|
|
420
|
+
sha_display,
|
|
421
|
+
age_display,
|
|
389
422
|
superset_url,
|
|
390
423
|
aws_logs_link,
|
|
391
|
-
|
|
392
|
-
|
|
424
|
+
ttl_display,
|
|
425
|
+
user_display,
|
|
393
426
|
)
|
|
394
427
|
|
|
395
428
|
p(table)
|
|
@@ -537,11 +570,8 @@ def sync(
|
|
|
537
570
|
|
|
538
571
|
if check_only:
|
|
539
572
|
# Analysis mode - just return what's needed
|
|
540
|
-
|
|
541
|
-
p(
|
|
542
|
-
p(f"sync_needed={str(analysis_result.sync_needed).lower()}")
|
|
543
|
-
p(f"pr_number={pr_number}")
|
|
544
|
-
p(f"target_sha={target_sha}")
|
|
573
|
+
sync_state = pr.analyze(target_sha, pr_state)
|
|
574
|
+
p(sync_state.to_gha_stdout(pr_number))
|
|
545
575
|
return
|
|
546
576
|
|
|
547
577
|
# Execution mode - do the sync
|
|
@@ -679,13 +709,23 @@ def cleanup(
|
|
|
679
709
|
return
|
|
680
710
|
|
|
681
711
|
cleaned_count = 0
|
|
712
|
+
orphan_cleaned_count = 0
|
|
713
|
+
|
|
682
714
|
for pr_number in pr_numbers:
|
|
683
715
|
pr = PullRequest.from_id(pr_number)
|
|
716
|
+
|
|
717
|
+
# Clean expired environments with pointers
|
|
684
718
|
if pr.stop_if_expired(max_age_hours, dry_run):
|
|
685
719
|
cleaned_count += 1
|
|
686
720
|
|
|
687
|
-
|
|
688
|
-
|
|
721
|
+
# Clean orphaned environments without pointers
|
|
722
|
+
orphan_cleaned_count += pr.cleanup_orphaned_shows(max_age_hours, dry_run)
|
|
723
|
+
|
|
724
|
+
if cleaned_count > 0 or orphan_cleaned_count > 0:
|
|
725
|
+
if cleaned_count > 0:
|
|
726
|
+
p(f"🎪 ✅ Cleaned up {cleaned_count} expired environments")
|
|
727
|
+
if orphan_cleaned_count > 0:
|
|
728
|
+
p(f"🎪 ✅ Cleaned up {orphan_cleaned_count} orphaned environments")
|
|
689
729
|
else:
|
|
690
730
|
p("🎪 No expired environments found")
|
|
691
731
|
|
|
@@ -718,20 +758,33 @@ def cleanup(
|
|
|
718
758
|
if len(aws_orphans) > 3:
|
|
719
759
|
p(f" ... and {len(aws_orphans) - 3} more")
|
|
720
760
|
|
|
721
|
-
if
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if not dry_run:
|
|
726
|
-
aws.delete_service(orphan["service_name"])
|
|
727
|
-
aws_cleaned_count += 1
|
|
728
|
-
else:
|
|
729
|
-
p("❌ Skipping AWS orphan cleanup")
|
|
730
|
-
elif force or dry_run:
|
|
761
|
+
# Determine if we should proceed with cleanup
|
|
762
|
+
should_cleanup = False
|
|
763
|
+
if dry_run:
|
|
764
|
+
should_cleanup = True
|
|
731
765
|
aws_cleaned_count = len(aws_orphans)
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
766
|
+
elif force:
|
|
767
|
+
should_cleanup = True
|
|
768
|
+
elif typer.confirm(f"Delete {len(aws_orphans)} orphaned AWS resources?"):
|
|
769
|
+
should_cleanup = True
|
|
770
|
+
else:
|
|
771
|
+
p("❌ Skipping AWS orphan cleanup")
|
|
772
|
+
|
|
773
|
+
# Perform cleanup if approved
|
|
774
|
+
if should_cleanup and not dry_run:
|
|
775
|
+
from .core.service_name import ServiceName
|
|
776
|
+
|
|
777
|
+
for orphan in aws_orphans:
|
|
778
|
+
service_name_str = orphan["service_name"]
|
|
779
|
+
try:
|
|
780
|
+
# Parse service name to get PR number
|
|
781
|
+
svc = ServiceName.from_service_name(service_name_str)
|
|
782
|
+
# Pass base name (without -service) to delete_environment
|
|
783
|
+
aws.delete_environment(svc.base_name, svc.pr_number)
|
|
784
|
+
aws_cleaned_count += 1
|
|
785
|
+
except ValueError as e:
|
|
786
|
+
p(f"⚠️ Skipping invalid service name {service_name_str}: {e}")
|
|
787
|
+
continue
|
|
735
788
|
|
|
736
789
|
if aws_cleaned_count > 0:
|
|
737
790
|
p(f"☁️ ✅ Cleaned up {aws_cleaned_count} orphaned AWS resources")
|
showtime/core/aws.py
CHANGED
|
@@ -74,16 +74,16 @@ class AWSInterface:
|
|
|
74
74
|
4. Deploy and wait for stability
|
|
75
75
|
5. Health check and return IP
|
|
76
76
|
"""
|
|
77
|
-
from datetime import datetime
|
|
78
77
|
|
|
78
|
+
# Create Show object for consistent AWS naming
|
|
79
|
+
from .date_utils import format_utc_now
|
|
79
80
|
from .show import Show
|
|
80
81
|
|
|
81
|
-
# Create Show object for consistent AWS naming
|
|
82
82
|
show = Show(
|
|
83
83
|
pr_number=pr_number,
|
|
84
84
|
sha=sha[:7], # Truncate to 7 chars like GitHub
|
|
85
85
|
status="building",
|
|
86
|
-
created_at=
|
|
86
|
+
created_at=format_utc_now(),
|
|
87
87
|
requested_by=github_user,
|
|
88
88
|
)
|
|
89
89
|
|
|
@@ -173,67 +173,46 @@ class AWSInterface:
|
|
|
173
173
|
except Exception as e:
|
|
174
174
|
return EnvironmentResult(success=False, error=str(e))
|
|
175
175
|
|
|
176
|
-
def delete_environment(self,
|
|
176
|
+
def delete_environment(self, base_name: str, pr_number: int) -> bool:
|
|
177
177
|
"""
|
|
178
|
-
Delete ephemeral environment
|
|
178
|
+
Delete ephemeral environment
|
|
179
179
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
3. Delete ECR image tag
|
|
184
|
-
4. Verify deletion completed
|
|
180
|
+
Args:
|
|
181
|
+
base_name: Base service name WITHOUT -service suffix (e.g., "pr-34868-abc123f")
|
|
182
|
+
pr_number: PR number for this environment
|
|
185
183
|
"""
|
|
186
184
|
try:
|
|
187
|
-
|
|
185
|
+
# Simple: always add -service suffix
|
|
186
|
+
ecs_service_name = f"{base_name}-service"
|
|
188
187
|
print(f"🗑️ Deleting ECS service: {ecs_service_name}")
|
|
189
|
-
|
|
190
|
-
# Step 1: Check if service exists
|
|
191
|
-
if not self._service_exists(ecs_service_name):
|
|
192
|
-
print(f"✅ Service {ecs_service_name} already deleted")
|
|
193
|
-
return True
|
|
194
|
-
|
|
195
|
-
# Step 2: Delete ECS service (force delete) and wait
|
|
196
|
-
print(f"☁️ Initiating ECS service deletion...")
|
|
197
|
-
delete_response = self.ecs_client.delete_service(
|
|
198
|
-
cluster=self.cluster,
|
|
199
|
-
service=ecs_service_name,
|
|
200
|
-
force=True
|
|
201
|
-
)
|
|
202
|
-
print(f"🔄 Delete initiated: {delete_response.get('service', {}).get('status', 'unknown')}")
|
|
203
|
-
|
|
204
|
-
# Step 3: Wait for deletion to complete (crucial!)
|
|
205
|
-
print(f"⏳ Waiting for service deletion to complete...")
|
|
206
|
-
deletion_success = self._wait_for_service_deletion(ecs_service_name, timeout_minutes=10)
|
|
207
|
-
|
|
208
|
-
if not deletion_success:
|
|
209
|
-
print(f"⚠️ Service deletion timeout - service may still exist")
|
|
210
|
-
return False
|
|
211
188
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
189
|
+
# Delete ECS service with force flag (AWS will handle cleanup)
|
|
190
|
+
try:
|
|
191
|
+
self.ecs_client.delete_service(
|
|
192
|
+
cluster=self.cluster, service=ecs_service_name, force=True
|
|
193
|
+
)
|
|
194
|
+
print(f"✅ ECS service deletion initiated: {ecs_service_name}")
|
|
195
|
+
except self.ecs_client.exceptions.ServiceNotFoundException:
|
|
196
|
+
print(f"ℹ️ Service {ecs_service_name} already deleted")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"❌ AWS deletion failed: {e}")
|
|
199
|
+
return False
|
|
220
200
|
|
|
201
|
+
# Try to clean up ECR image - for showtime services, tag is base_name + "-ci"
|
|
221
202
|
try:
|
|
203
|
+
image_tag = f"{base_name}-ci"
|
|
222
204
|
self.ecr_client.batch_delete_image(
|
|
223
205
|
repositoryName=self.repository, imageIds=[{"imageTag": image_tag}]
|
|
224
206
|
)
|
|
225
207
|
print(f"✅ Deleted ECR image: {image_tag}")
|
|
226
|
-
except
|
|
227
|
-
|
|
208
|
+
except Exception:
|
|
209
|
+
pass # Image cleanup is optional
|
|
228
210
|
|
|
229
|
-
print(f"✅ Environment {service_name} fully deleted")
|
|
230
211
|
return True
|
|
231
212
|
|
|
232
213
|
except Exception as e:
|
|
233
|
-
print(f"❌
|
|
234
|
-
|
|
235
|
-
message=str(e), operation="delete_environment", resource=service_name
|
|
236
|
-
) from e
|
|
214
|
+
print(f"❌ Unexpected error: {e}")
|
|
215
|
+
return False
|
|
237
216
|
|
|
238
217
|
def get_environment_ip(self, service_name: str) -> Optional[str]:
|
|
239
218
|
"""
|
|
@@ -701,19 +680,19 @@ class AWSInterface:
|
|
|
701
680
|
try:
|
|
702
681
|
# List all services in cluster
|
|
703
682
|
response = self.ecs_client.list_services(cluster=self.cluster)
|
|
704
|
-
|
|
683
|
+
|
|
705
684
|
if not response.get("serviceArns"):
|
|
706
685
|
return []
|
|
707
|
-
|
|
686
|
+
|
|
708
687
|
# Extract service names and filter for showtime pattern
|
|
709
688
|
showtime_services = []
|
|
710
689
|
for service_arn in response["serviceArns"]:
|
|
711
690
|
service_name = service_arn.split("/")[-1] # Extract name from ARN
|
|
712
691
|
if service_name.startswith("pr-") and "-service" in service_name:
|
|
713
692
|
showtime_services.append(service_name)
|
|
714
|
-
|
|
693
|
+
|
|
715
694
|
return sorted(showtime_services)
|
|
716
|
-
|
|
695
|
+
|
|
717
696
|
except Exception as e:
|
|
718
697
|
print(f"❌ Failed to find showtime services: {e}")
|
|
719
698
|
return []
|
|
@@ -845,10 +824,15 @@ class AWSInterface:
|
|
|
845
824
|
for attempt in range(max_attempts):
|
|
846
825
|
# Check if service still exists
|
|
847
826
|
if not self._service_exists(service_name):
|
|
848
|
-
|
|
827
|
+
if attempt == 0:
|
|
828
|
+
print(
|
|
829
|
+
f"✅ Service {service_name} deletion confirmed (was already draining)"
|
|
830
|
+
)
|
|
831
|
+
else:
|
|
832
|
+
print(f"✅ Service {service_name} fully deleted after {attempt * 5}s")
|
|
849
833
|
return True
|
|
850
834
|
|
|
851
|
-
if attempt % 6 == 0: # Every 30s
|
|
835
|
+
if attempt % 6 == 0 and attempt > 0: # Every 30s after first check
|
|
852
836
|
print(f"⏳ Waiting for service deletion... ({attempt * 5}s elapsed)")
|
|
853
837
|
|
|
854
838
|
time.sleep(5) # Check every 5 seconds
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Constants used throughout the showtime codebase."""
|
|
2
|
+
|
|
3
|
+
# Default time-to-live for new environments
|
|
4
|
+
DEFAULT_TTL = "48h"
|
|
5
|
+
|
|
6
|
+
# TTL options for environments
|
|
7
|
+
TTL_OPTIONS = ["24h", "48h", "72h", "1w", "close"]
|
|
8
|
+
|
|
9
|
+
# Maximum age for considering environments stale/orphaned
|
|
10
|
+
DEFAULT_CLEANUP_AGE = "48h"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Date and time utilities for consistent timestamp handling."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
# Custom timestamp format used in circus labels
|
|
7
|
+
# Format: YYYY-MM-DDTHH-MM (using dashes instead of colons for GitHub label compatibility)
|
|
8
|
+
CIRCUS_TIME_FORMAT = "%Y-%m-%dT%H-%M"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_utc_now() -> str:
|
|
12
|
+
"""Get current UTC time formatted for circus labels."""
|
|
13
|
+
return datetime.utcnow().strftime(CIRCUS_TIME_FORMAT)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_circus_time(timestamp: str) -> Optional[datetime]:
|
|
17
|
+
"""Parse a circus timestamp string into a datetime object.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
timestamp: String in format "YYYY-MM-DDTHH-MM"
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
datetime object or None if parsing fails
|
|
24
|
+
"""
|
|
25
|
+
if not timestamp:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
return datetime.strptime(timestamp, CIRCUS_TIME_FORMAT)
|
|
30
|
+
except (ValueError, AttributeError):
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def age_display(created_at: str) -> str:
|
|
35
|
+
"""Convert a circus timestamp to human-readable age.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
created_at: Timestamp string in circus format
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Human-readable age like "2d 5h" or "45m"
|
|
42
|
+
"""
|
|
43
|
+
created_dt = parse_circus_time(created_at)
|
|
44
|
+
if not created_dt:
|
|
45
|
+
return "-"
|
|
46
|
+
|
|
47
|
+
# Compare UTC to UTC for accurate age
|
|
48
|
+
age = datetime.utcnow() - created_dt
|
|
49
|
+
|
|
50
|
+
# Format age nicely
|
|
51
|
+
days = age.days
|
|
52
|
+
hours = age.seconds // 3600
|
|
53
|
+
minutes = (age.seconds % 3600) // 60
|
|
54
|
+
|
|
55
|
+
if days > 0:
|
|
56
|
+
return f"{days}d {hours}h"
|
|
57
|
+
elif hours > 0:
|
|
58
|
+
return f"{hours}h {minutes}m"
|
|
59
|
+
else:
|
|
60
|
+
return f"{minutes}m"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_expired(created_at: str, max_age_hours: int) -> bool:
|
|
64
|
+
"""Check if a timestamp is older than the specified hours.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
created_at: Timestamp string in circus format
|
|
68
|
+
max_age_hours: Maximum age in hours
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if timestamp is older than max_age_hours
|
|
72
|
+
"""
|
|
73
|
+
created_dt = parse_circus_time(created_at)
|
|
74
|
+
if not created_dt:
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
from datetime import timedelta
|
|
78
|
+
|
|
79
|
+
expiry_time = created_dt + timedelta(hours=max_age_hours)
|
|
80
|
+
return datetime.utcnow() > expiry_time
|
showtime/core/git_validation.py
CHANGED
|
@@ -5,14 +5,17 @@ Validates that the current Git repository contains required commit SHA to preven
|
|
|
5
5
|
usage with outdated releases.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from typing import Optional, Tuple
|
|
8
|
+
from typing import TYPE_CHECKING, Optional, Tuple
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
11
|
from git import InvalidGitRepositoryError, Repo
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
else:
|
|
13
|
+
try:
|
|
14
|
+
from git import InvalidGitRepositoryError, Repo
|
|
15
|
+
except ImportError:
|
|
16
|
+
# Fallback if GitPython is not available
|
|
17
|
+
Repo = None
|
|
18
|
+
InvalidGitRepositoryError = Exception
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
# Hard-coded required SHA - update this when needed
|
|
@@ -36,7 +39,9 @@ def is_git_repository(path: str = ".") -> bool:
|
|
|
36
39
|
Returns:
|
|
37
40
|
True if it's a Git repository, False otherwise
|
|
38
41
|
"""
|
|
39
|
-
|
|
42
|
+
try:
|
|
43
|
+
from git import InvalidGitRepositoryError, Repo
|
|
44
|
+
except ImportError:
|
|
40
45
|
# GitPython not available, assume not a git repo
|
|
41
46
|
return False
|
|
42
47
|
|
|
@@ -69,9 +74,12 @@ def validate_required_sha(required_sha: Optional[str] = None) -> Tuple[bool, Opt
|
|
|
69
74
|
return _validate_sha_via_github_api(sha_to_check)
|
|
70
75
|
except Exception as e:
|
|
71
76
|
print(f"⚠️ GitHub API validation failed: {e}")
|
|
77
|
+
# Fall through to Git validation
|
|
72
78
|
|
|
73
79
|
# Fallback to Git validation for non-GitHub origins
|
|
74
|
-
|
|
80
|
+
try:
|
|
81
|
+
from git import Repo
|
|
82
|
+
except ImportError:
|
|
75
83
|
print("⚠️ GitPython not available, skipping SHA validation")
|
|
76
84
|
return True, None
|
|
77
85
|
|
|
@@ -84,9 +92,6 @@ def validate_required_sha(required_sha: Optional[str] = None) -> Tuple[bool, Opt
|
|
|
84
92
|
print(f"⚠️ Git validation failed: {error}")
|
|
85
93
|
return True, None # Allow operation to continue
|
|
86
94
|
|
|
87
|
-
except InvalidGitRepositoryError:
|
|
88
|
-
print("⚠️ Not a Git repository, skipping SHA validation")
|
|
89
|
-
return True, None
|
|
90
95
|
except Exception as e:
|
|
91
96
|
print(f"⚠️ Git validation error: {e}")
|
|
92
97
|
return True, None
|
|
@@ -181,13 +186,17 @@ def get_validation_error_message(required_sha: Optional[str] = None) -> str:
|
|
|
181
186
|
|
|
182
187
|
This branch requires commit {sha_to_check} to be present in your Git history.
|
|
183
188
|
|
|
189
|
+
[bold yellow]Why this commit is required:[/bold yellow]
|
|
190
|
+
Showtime depends on Docker build infrastructure (LOAD_EXAMPLES_DUCKDB) and DuckDB
|
|
191
|
+
examples support that was introduced in this commit. Without it, Docker builds will fail.
|
|
192
|
+
|
|
184
193
|
[bold yellow]To resolve this:[/bold yellow]
|
|
185
194
|
1. Ensure you're on the correct branch (usually main)
|
|
186
195
|
2. Pull the latest changes: [cyan]git pull origin main[/cyan]
|
|
187
196
|
3. Verify the commit exists: [cyan]git log --oneline | grep {sha_to_check[:7]}[/cyan]
|
|
188
197
|
4. If needed, switch to main branch: [cyan]git checkout main[/cyan]
|
|
189
198
|
|
|
190
|
-
[dim]This
|
|
199
|
+
[dim]This prevents Docker build failures on PRs missing required infrastructure.[/dim]
|
|
191
200
|
""".strip()
|
|
192
201
|
|
|
193
202
|
|
showtime/core/github.py
CHANGED
|
@@ -11,6 +11,9 @@ from typing import Any, Dict, List, Optional
|
|
|
11
11
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
|
+
# Constants
|
|
15
|
+
DEFAULT_GITHUB_ACTOR = "unknown"
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
@dataclass
|
|
16
19
|
class GitHubError(Exception):
|
|
@@ -53,6 +56,22 @@ class GitHubInterface:
|
|
|
53
56
|
|
|
54
57
|
return None
|
|
55
58
|
|
|
59
|
+
# GitHub Actor Resolution
|
|
60
|
+
@staticmethod
|
|
61
|
+
def get_current_actor() -> str:
|
|
62
|
+
"""Get current GitHub actor with consistent fallback across the codebase"""
|
|
63
|
+
return os.getenv("GITHUB_ACTOR", DEFAULT_GITHUB_ACTOR)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def get_actor_debug_info() -> dict:
|
|
67
|
+
"""Get debug information about GitHub actor context"""
|
|
68
|
+
raw_actor = os.getenv("GITHUB_ACTOR") # Could be None/empty
|
|
69
|
+
return {
|
|
70
|
+
"actor": GitHubInterface.get_current_actor(),
|
|
71
|
+
"is_github_actions": os.getenv("GITHUB_ACTIONS") == "true",
|
|
72
|
+
"raw_actor": raw_actor or "none",
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
@property
|
|
57
76
|
def headers(self) -> Dict[str, str]:
|
|
58
77
|
"""HTTP headers for GitHub API requests"""
|