superset-showtime 0.6.4__py3-none-any.whl → 0.6.6__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 +65 -50
- 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 +248 -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.6.dist-info}/METADATA +1 -1
- superset_showtime-0.6.6.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.6.dist-info}/WHEEL +0 -0
- {superset_showtime-0.6.4.dist-info → superset_showtime-0.6.6.dist-info}/entry_points.txt +0 -0
showtime/core/pull_request.py
CHANGED
|
@@ -4,14 +4,13 @@
|
|
|
4
4
|
Handles atomic transactions, trigger processing, and environment orchestration.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import os
|
|
8
7
|
from dataclasses import dataclass
|
|
9
|
-
from datetime import datetime
|
|
10
8
|
from typing import Any, List, Optional
|
|
11
9
|
|
|
12
10
|
from .aws import AWSInterface
|
|
13
11
|
from .github import GitHubInterface
|
|
14
12
|
from .show import Show, short_sha
|
|
13
|
+
from .sync_state import ActionNeeded, AuthStatus, BlockedReason, SyncState
|
|
15
14
|
|
|
16
15
|
# Lazy singletons to avoid import-time failures
|
|
17
16
|
_github = None
|
|
@@ -127,7 +126,7 @@ class PullRequest:
|
|
|
127
126
|
# Create Show objects for each SHA
|
|
128
127
|
shows = []
|
|
129
128
|
for sha in shas:
|
|
130
|
-
show = Show.from_circus_labels(self.pr_number, self.labels, sha)
|
|
129
|
+
show = Show.from_circus_labels(self.pr_number, list(self.labels), sha)
|
|
131
130
|
if show:
|
|
132
131
|
shows.append(show)
|
|
133
132
|
|
|
@@ -213,19 +212,27 @@ class PullRequest:
|
|
|
213
212
|
active_pointer = f"{active_prefix}{show.sha}" # "🎪 🎯 abc123f"
|
|
214
213
|
self.add_label(active_pointer)
|
|
215
214
|
|
|
216
|
-
def _check_authorization(self) -> bool:
|
|
217
|
-
"""Check if current GitHub actor is authorized for operations
|
|
218
|
-
import os
|
|
215
|
+
def _check_authorization(self) -> tuple[bool, dict]:
|
|
216
|
+
"""Check if current GitHub actor is authorized for operations
|
|
219
217
|
|
|
218
|
+
Returns:
|
|
219
|
+
tuple: (is_authorized, debug_info_dict)
|
|
220
|
+
"""
|
|
220
221
|
import httpx
|
|
221
222
|
|
|
223
|
+
# Get actor info using centralized function
|
|
224
|
+
actor_info = GitHubInterface.get_actor_debug_info()
|
|
225
|
+
debug_info = {**actor_info, "permission": "unknown", "auth_status": "unknown"}
|
|
226
|
+
|
|
222
227
|
# Only check in GitHub Actions context
|
|
223
|
-
if
|
|
224
|
-
|
|
228
|
+
if not debug_info["is_github_actions"]:
|
|
229
|
+
debug_info["auth_status"] = "skipped_not_actions"
|
|
230
|
+
return True, debug_info
|
|
225
231
|
|
|
226
|
-
actor =
|
|
227
|
-
if not actor:
|
|
228
|
-
|
|
232
|
+
actor = debug_info["actor"]
|
|
233
|
+
if not actor or actor == "unknown":
|
|
234
|
+
debug_info["auth_status"] = "allowed_no_actor"
|
|
235
|
+
return True, debug_info
|
|
229
236
|
|
|
230
237
|
try:
|
|
231
238
|
# Use existing GitHubInterface for consistency
|
|
@@ -237,58 +244,202 @@ class PullRequest:
|
|
|
237
244
|
with httpx.Client() as client:
|
|
238
245
|
response = client.get(perm_url, headers=github.headers)
|
|
239
246
|
if response.status_code == 404:
|
|
240
|
-
|
|
247
|
+
debug_info["permission"] = "not_collaborator"
|
|
248
|
+
debug_info["auth_status"] = "denied_404"
|
|
249
|
+
return False, debug_info
|
|
250
|
+
|
|
241
251
|
response.raise_for_status()
|
|
242
252
|
|
|
243
253
|
data = response.json()
|
|
244
254
|
permission = data.get("permission", "none")
|
|
255
|
+
debug_info["permission"] = permission
|
|
245
256
|
|
|
246
257
|
# Allow write and admin permissions only
|
|
247
258
|
authorized = permission in ["write", "admin"]
|
|
248
259
|
|
|
249
260
|
if not authorized:
|
|
261
|
+
debug_info["auth_status"] = "denied_insufficient_perms"
|
|
250
262
|
print(f"🚨 Unauthorized actor {actor} (permission: {permission})")
|
|
251
263
|
# Set blocked label for security
|
|
252
264
|
self.add_label("🎪 🔒 showtime-blocked")
|
|
265
|
+
else:
|
|
266
|
+
debug_info["auth_status"] = "authorized"
|
|
253
267
|
|
|
254
|
-
return authorized
|
|
268
|
+
return authorized, debug_info
|
|
255
269
|
|
|
256
270
|
except Exception as e:
|
|
271
|
+
debug_info["auth_status"] = f"error_{type(e).__name__}"
|
|
272
|
+
debug_info["error"] = str(e)
|
|
257
273
|
print(f"⚠️ Authorization check failed: {e}")
|
|
258
|
-
return True # Fail open for non-security operations
|
|
274
|
+
return True, debug_info # Fail open for non-security operations
|
|
259
275
|
|
|
260
|
-
def analyze(self, target_sha: str, pr_state: str = "open") ->
|
|
261
|
-
"""Analyze what actions are needed
|
|
276
|
+
def analyze(self, target_sha: str, pr_state: str = "open") -> SyncState:
|
|
277
|
+
"""Analyze what actions are needed with comprehensive debugging info
|
|
262
278
|
|
|
263
279
|
Args:
|
|
264
280
|
target_sha: Target commit SHA to analyze
|
|
265
281
|
pr_state: PR state (open/closed)
|
|
266
282
|
|
|
267
283
|
Returns:
|
|
268
|
-
|
|
284
|
+
SyncState with complete analysis and debug info
|
|
269
285
|
"""
|
|
286
|
+
import os
|
|
287
|
+
|
|
270
288
|
# Handle closed PRs
|
|
271
289
|
if pr_state == "closed":
|
|
272
|
-
return
|
|
273
|
-
action_needed=
|
|
290
|
+
return SyncState(
|
|
291
|
+
action_needed=ActionNeeded.DESTROY_ENVIRONMENT,
|
|
292
|
+
build_needed=False,
|
|
293
|
+
sync_needed=True,
|
|
294
|
+
target_sha=target_sha,
|
|
295
|
+
github_actor=GitHubInterface.get_current_actor(),
|
|
296
|
+
is_github_actions=os.getenv("GITHUB_ACTIONS") == "true",
|
|
297
|
+
permission_level="cleanup",
|
|
298
|
+
auth_status=AuthStatus.SKIPPED_NOT_ACTIONS,
|
|
299
|
+
action_reason="pr_closed",
|
|
274
300
|
)
|
|
275
301
|
|
|
276
|
-
#
|
|
277
|
-
|
|
302
|
+
# Get fresh labels
|
|
303
|
+
self.refresh_labels()
|
|
304
|
+
|
|
305
|
+
# Initialize state tracking
|
|
306
|
+
target_sha_short = target_sha[:7]
|
|
307
|
+
target_show = self.get_show_by_sha(target_sha_short)
|
|
308
|
+
trigger_labels = [label for label in self.labels if "showtime-trigger-" in label]
|
|
278
309
|
|
|
279
|
-
#
|
|
280
|
-
|
|
310
|
+
# Check for existing blocked label
|
|
311
|
+
blocked_reason = BlockedReason.NOT_BLOCKED
|
|
312
|
+
if "🎪 🔒 showtime-blocked" in self.labels:
|
|
313
|
+
blocked_reason = BlockedReason.EXISTING_BLOCKED_LABEL
|
|
281
314
|
|
|
282
|
-
#
|
|
283
|
-
|
|
315
|
+
# Check authorization
|
|
316
|
+
is_authorized, auth_debug = self._check_authorization()
|
|
317
|
+
if not is_authorized and blocked_reason == BlockedReason.NOT_BLOCKED:
|
|
318
|
+
blocked_reason = BlockedReason.AUTHORIZATION_FAILED
|
|
319
|
+
|
|
320
|
+
# Determine action needed
|
|
321
|
+
action_needed_str = (
|
|
322
|
+
"blocked"
|
|
323
|
+
if blocked_reason != BlockedReason.NOT_BLOCKED
|
|
324
|
+
else self._evaluate_action_logic(target_sha_short, target_show, trigger_labels)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Map string to enum
|
|
328
|
+
action_map = {
|
|
329
|
+
"no_action": ActionNeeded.NO_ACTION,
|
|
330
|
+
"create_environment": ActionNeeded.CREATE_ENVIRONMENT,
|
|
331
|
+
"rolling_update": ActionNeeded.ROLLING_UPDATE,
|
|
332
|
+
"auto_sync": ActionNeeded.AUTO_SYNC,
|
|
333
|
+
"destroy_environment": ActionNeeded.DESTROY_ENVIRONMENT,
|
|
334
|
+
"blocked": ActionNeeded.BLOCKED,
|
|
335
|
+
}
|
|
336
|
+
action_needed = action_map.get(action_needed_str, ActionNeeded.NO_ACTION)
|
|
284
337
|
|
|
285
|
-
|
|
338
|
+
# Build sync state
|
|
339
|
+
return SyncState(
|
|
286
340
|
action_needed=action_needed,
|
|
287
|
-
build_needed=
|
|
288
|
-
|
|
341
|
+
build_needed=action_needed
|
|
342
|
+
in [
|
|
343
|
+
ActionNeeded.CREATE_ENVIRONMENT,
|
|
344
|
+
ActionNeeded.ROLLING_UPDATE,
|
|
345
|
+
ActionNeeded.AUTO_SYNC,
|
|
346
|
+
],
|
|
347
|
+
sync_needed=action_needed not in [ActionNeeded.NO_ACTION, ActionNeeded.BLOCKED],
|
|
289
348
|
target_sha=target_sha,
|
|
349
|
+
github_actor=auth_debug.get("actor", "unknown"),
|
|
350
|
+
is_github_actions=auth_debug.get("is_github_actions", False),
|
|
351
|
+
permission_level=auth_debug.get("permission", "unknown"),
|
|
352
|
+
auth_status=self._parse_auth_status(auth_debug.get("auth_status", "unknown")),
|
|
353
|
+
blocked_reason=blocked_reason,
|
|
354
|
+
trigger_labels=trigger_labels,
|
|
355
|
+
target_show_status=target_show.status if target_show else None,
|
|
356
|
+
has_previous_shows=len(self.shows) > 0,
|
|
357
|
+
action_reason=self._get_action_reason(action_needed_str, target_show, trigger_labels),
|
|
358
|
+
auth_error=auth_debug.get("error"),
|
|
290
359
|
)
|
|
291
360
|
|
|
361
|
+
def _evaluate_action_logic(
|
|
362
|
+
self, target_sha_short: str, target_show: Optional[Show], trigger_labels: List[str]
|
|
363
|
+
) -> str:
|
|
364
|
+
"""Pure logic for evaluating what action is needed (no side effects, for testability)"""
|
|
365
|
+
if trigger_labels:
|
|
366
|
+
for trigger in trigger_labels:
|
|
367
|
+
if "showtime-trigger-start" in trigger:
|
|
368
|
+
if not target_show or target_show.status == "failed":
|
|
369
|
+
return "create_environment" # New SHA or failed SHA
|
|
370
|
+
elif target_show.status in ["building", "built", "deploying"]:
|
|
371
|
+
return "no_action" # Target SHA already in progress
|
|
372
|
+
elif target_show.status == "running":
|
|
373
|
+
return "create_environment" # Force rebuild with trigger
|
|
374
|
+
else:
|
|
375
|
+
return "create_environment" # Default for unknown states
|
|
376
|
+
elif "showtime-trigger-stop" in trigger:
|
|
377
|
+
return "destroy_environment"
|
|
378
|
+
|
|
379
|
+
# No explicit triggers - only auto-create if there's ANY previous environment
|
|
380
|
+
if not target_show:
|
|
381
|
+
# Target SHA doesn't exist - only create if there's any previous environment
|
|
382
|
+
if self.shows: # Any previous environment exists
|
|
383
|
+
return "create_environment"
|
|
384
|
+
else:
|
|
385
|
+
# No previous environments - don't auto-create without explicit trigger
|
|
386
|
+
return "no_action"
|
|
387
|
+
elif target_show.status == "failed":
|
|
388
|
+
# Target SHA failed - rebuild it
|
|
389
|
+
return "create_environment"
|
|
390
|
+
elif target_show.status in ["building", "built", "deploying"]:
|
|
391
|
+
# Target SHA in progress - wait
|
|
392
|
+
return "no_action"
|
|
393
|
+
elif target_show.status == "running":
|
|
394
|
+
# Target SHA already running - no action needed
|
|
395
|
+
return "no_action"
|
|
396
|
+
|
|
397
|
+
return "no_action"
|
|
398
|
+
|
|
399
|
+
def _get_action_reason(
|
|
400
|
+
self, action_needed: str, target_show: Optional[Show], trigger_labels: List[str]
|
|
401
|
+
) -> str:
|
|
402
|
+
"""Get human-readable reason for the action"""
|
|
403
|
+
if action_needed == "blocked":
|
|
404
|
+
return "operation_blocked"
|
|
405
|
+
elif trigger_labels:
|
|
406
|
+
if any("trigger-start" in label for label in trigger_labels):
|
|
407
|
+
if not target_show:
|
|
408
|
+
return "explicit_start_new_sha"
|
|
409
|
+
elif target_show.status == "failed":
|
|
410
|
+
return "explicit_start_failed_sha"
|
|
411
|
+
elif target_show.status == "running":
|
|
412
|
+
return "explicit_start_force_rebuild"
|
|
413
|
+
else:
|
|
414
|
+
return "explicit_start_trigger"
|
|
415
|
+
elif any("trigger-stop" in label for label in trigger_labels):
|
|
416
|
+
return "explicit_stop_trigger"
|
|
417
|
+
elif action_needed == "create_environment":
|
|
418
|
+
if not target_show:
|
|
419
|
+
return "auto_sync_new_commit"
|
|
420
|
+
elif target_show.status == "failed":
|
|
421
|
+
return "auto_rebuild_failed"
|
|
422
|
+
else:
|
|
423
|
+
return "create_environment"
|
|
424
|
+
elif action_needed == "no_action":
|
|
425
|
+
if target_show and target_show.status == "running":
|
|
426
|
+
return "already_running"
|
|
427
|
+
elif target_show and target_show.status in ["building", "deploying"]:
|
|
428
|
+
return "in_progress"
|
|
429
|
+
else:
|
|
430
|
+
return "no_previous_environments"
|
|
431
|
+
return action_needed
|
|
432
|
+
|
|
433
|
+
def _parse_auth_status(self, auth_status_str: str) -> AuthStatus:
|
|
434
|
+
"""Parse auth status string to enum, handling errors gracefully"""
|
|
435
|
+
try:
|
|
436
|
+
return AuthStatus(auth_status_str)
|
|
437
|
+
except ValueError:
|
|
438
|
+
# Handle error cases that include exception type (e.g., "error_UnsupportedProtocol")
|
|
439
|
+
if auth_status_str.startswith("error_"):
|
|
440
|
+
return AuthStatus.ERROR
|
|
441
|
+
return AuthStatus.ERROR
|
|
442
|
+
|
|
292
443
|
def sync(
|
|
293
444
|
self,
|
|
294
445
|
target_sha: str,
|
|
@@ -414,8 +565,13 @@ class PullRequest:
|
|
|
414
565
|
# Stop the current environment if it exists
|
|
415
566
|
if self.current_show:
|
|
416
567
|
print(f"🗑️ Destroying environment {self.current_show.sha}...")
|
|
417
|
-
self.current_show.stop(
|
|
418
|
-
|
|
568
|
+
success = self.current_show.stop(
|
|
569
|
+
dry_run_github=dry_run_github, dry_run_aws=dry_run_aws
|
|
570
|
+
)
|
|
571
|
+
if success:
|
|
572
|
+
print("☁️ AWS resources deleted")
|
|
573
|
+
else:
|
|
574
|
+
print("⚠️ AWS resource deletion may have failed")
|
|
419
575
|
self._post_cleanup_comment(self.current_show, dry_run_github)
|
|
420
576
|
else:
|
|
421
577
|
print("🗑️ No current environment to destroy")
|
|
@@ -448,8 +604,12 @@ class PullRequest:
|
|
|
448
604
|
try:
|
|
449
605
|
# Stop the current environment if it exists
|
|
450
606
|
if self.current_show:
|
|
451
|
-
self.current_show.stop(**kwargs)
|
|
452
|
-
|
|
607
|
+
success = self.current_show.stop(**kwargs)
|
|
608
|
+
if success:
|
|
609
|
+
print("☁️ AWS resources deleted")
|
|
610
|
+
else:
|
|
611
|
+
print("⚠️ AWS resource deletion may have failed")
|
|
612
|
+
return SyncResult(success=False, action_taken="stop_environment")
|
|
453
613
|
else:
|
|
454
614
|
print("🗑️ No current environment to destroy")
|
|
455
615
|
|
|
@@ -511,6 +671,7 @@ class PullRequest:
|
|
|
511
671
|
"ttl": show.ttl,
|
|
512
672
|
"requested_by": show.requested_by,
|
|
513
673
|
"created_at": show.created_at,
|
|
674
|
+
"age": show.age_display(), # Add age display
|
|
514
675
|
"aws_service_name": show.aws_service_name,
|
|
515
676
|
"show_type": show_type, # New field for display
|
|
516
677
|
},
|
|
@@ -520,7 +681,7 @@ class PullRequest:
|
|
|
520
681
|
return all_environments
|
|
521
682
|
|
|
522
683
|
def _determine_action(self, target_sha: str) -> str:
|
|
523
|
-
"""Determine what sync action is needed
|
|
684
|
+
"""Determine what sync action is needed (includes all checks and refreshes labels)"""
|
|
524
685
|
# CRITICAL: Get fresh labels before any decisions
|
|
525
686
|
self.refresh_labels()
|
|
526
687
|
|
|
@@ -529,7 +690,8 @@ class PullRequest:
|
|
|
529
690
|
return "blocked"
|
|
530
691
|
|
|
531
692
|
# Check authorization (security layer)
|
|
532
|
-
|
|
693
|
+
is_authorized, _ = self._check_authorization()
|
|
694
|
+
if not is_authorized:
|
|
533
695
|
return "blocked"
|
|
534
696
|
|
|
535
697
|
target_sha_short = target_sha[:7] # Ensure we're working with short SHA
|
|
@@ -626,13 +788,16 @@ class PullRequest:
|
|
|
626
788
|
|
|
627
789
|
def _create_new_show(self, target_sha: str) -> Show:
|
|
628
790
|
"""Create a new Show object for the target SHA"""
|
|
791
|
+
from .constants import DEFAULT_TTL
|
|
792
|
+
from .date_utils import format_utc_now
|
|
793
|
+
|
|
629
794
|
return Show(
|
|
630
795
|
pr_number=self.pr_number,
|
|
631
796
|
sha=short_sha(target_sha),
|
|
632
797
|
status="building",
|
|
633
|
-
created_at=
|
|
634
|
-
ttl=
|
|
635
|
-
requested_by=
|
|
798
|
+
created_at=format_utc_now(),
|
|
799
|
+
ttl=DEFAULT_TTL,
|
|
800
|
+
requested_by=GitHubInterface.get_current_actor(),
|
|
636
801
|
)
|
|
637
802
|
|
|
638
803
|
def _post_building_comment(self, show: Show, dry_run: bool = False) -> None:
|
|
@@ -705,6 +870,48 @@ class PullRequest:
|
|
|
705
870
|
|
|
706
871
|
return False # Not expired
|
|
707
872
|
|
|
873
|
+
def cleanup_orphaned_shows(self, max_age_hours: int, dry_run: bool = False) -> int:
|
|
874
|
+
"""Clean up orphaned shows (environments without pointer labels)
|
|
875
|
+
|
|
876
|
+
Args:
|
|
877
|
+
max_age_hours: Maximum age in hours before considering orphaned environment for cleanup
|
|
878
|
+
dry_run: If True, just check don't actually stop
|
|
879
|
+
|
|
880
|
+
Returns:
|
|
881
|
+
Number of orphaned environments cleaned up
|
|
882
|
+
"""
|
|
883
|
+
cleaned_count = 0
|
|
884
|
+
|
|
885
|
+
# Find orphaned shows (shows without active or building pointers)
|
|
886
|
+
orphaned_shows = []
|
|
887
|
+
for show in self.shows:
|
|
888
|
+
has_pointer = any(
|
|
889
|
+
label in self.labels for label in [f"🎪 🎯 {show.sha}", f"🎪 🏗️ {show.sha}"]
|
|
890
|
+
)
|
|
891
|
+
if not has_pointer and show.is_expired(max_age_hours):
|
|
892
|
+
orphaned_shows.append(show)
|
|
893
|
+
|
|
894
|
+
# Clean up each orphaned show
|
|
895
|
+
for show in orphaned_shows:
|
|
896
|
+
if dry_run:
|
|
897
|
+
print(
|
|
898
|
+
f"🎪 [DRY-RUN] Would clean orphaned environment: PR #{self.pr_number} SHA {show.sha}"
|
|
899
|
+
)
|
|
900
|
+
cleaned_count += 1
|
|
901
|
+
else:
|
|
902
|
+
print(f"🧹 Cleaning orphaned environment: PR #{self.pr_number} SHA {show.sha}")
|
|
903
|
+
# Stop the specific show (AWS resources)
|
|
904
|
+
success = show.stop(dry_run_github=False, dry_run_aws=False)
|
|
905
|
+
if success:
|
|
906
|
+
# Also clean up GitHub labels for this specific show
|
|
907
|
+
self.remove_sha_labels(show.sha)
|
|
908
|
+
cleaned_count += 1
|
|
909
|
+
print(f"✅ Cleaned orphaned environment: {show.sha}")
|
|
910
|
+
else:
|
|
911
|
+
print(f"⚠️ Failed to clean orphaned environment: {show.sha}")
|
|
912
|
+
|
|
913
|
+
return cleaned_count
|
|
914
|
+
|
|
708
915
|
@classmethod
|
|
709
916
|
def find_all_with_environments(cls) -> List[int]:
|
|
710
917
|
"""Find all PR numbers that have active environments"""
|
|
@@ -727,9 +934,11 @@ class PullRequest:
|
|
|
727
934
|
|
|
728
935
|
# For running environments, ensure only ONE active pointer exists
|
|
729
936
|
if show.status == "running":
|
|
730
|
-
# Remove ALL existing active pointers
|
|
937
|
+
# Remove ALL existing active pointers EXCEPT for this SHA's pointer
|
|
731
938
|
existing_active_pointers = [
|
|
732
|
-
label
|
|
939
|
+
label
|
|
940
|
+
for label in self.labels
|
|
941
|
+
if label.startswith("🎪 🎯 ") and label != f"🎪 🎯 {show.sha}"
|
|
733
942
|
]
|
|
734
943
|
for old_pointer in existing_active_pointers:
|
|
735
944
|
print(f"🎯 Removing old active pointer: {old_pointer}")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Service name parsing and generation utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ServiceName:
|
|
10
|
+
"""
|
|
11
|
+
Handles ECS service name parsing and generation.
|
|
12
|
+
|
|
13
|
+
Service names follow the pattern: pr-{pr_number}-{sha}-service
|
|
14
|
+
Where sha can be either full or short (7 chars).
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
pr-34868-service (legacy, no SHA)
|
|
18
|
+
pr-34868-abc123f-service (with short SHA)
|
|
19
|
+
pr-34868-abc123f456def-service (with full SHA)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
pr_number: int
|
|
23
|
+
sha: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_service_name(cls, service_name: str) -> "ServiceName":
|
|
27
|
+
"""
|
|
28
|
+
Parse a service name into its components.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
service_name: ECS service name like "pr-34868-abc123f-service"
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
ServiceName instance with parsed components
|
|
35
|
+
"""
|
|
36
|
+
# Remove -service suffix if present
|
|
37
|
+
base_name = service_name.replace("-service", "")
|
|
38
|
+
|
|
39
|
+
# Parse pattern: pr-{number}[-{sha}]
|
|
40
|
+
match = re.match(r"pr-(\d+)(?:-([a-f0-9]+))?", base_name)
|
|
41
|
+
if not match:
|
|
42
|
+
raise ValueError(f"Invalid service name format: {service_name}")
|
|
43
|
+
|
|
44
|
+
pr_number = int(match.group(1))
|
|
45
|
+
sha = match.group(2) # May be None for legacy services
|
|
46
|
+
|
|
47
|
+
return cls(pr_number=pr_number, sha=sha)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_base_name(cls, base_name: str, pr_number: int) -> "ServiceName":
|
|
51
|
+
"""
|
|
52
|
+
Create from base name (without pr- prefix and -service suffix).
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
base_name: Name like "pr-34868-abc123f"
|
|
56
|
+
pr_number: PR number for validation
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
ServiceName instance
|
|
60
|
+
"""
|
|
61
|
+
# Remove pr- prefix if present
|
|
62
|
+
if base_name.startswith("pr-"):
|
|
63
|
+
base_name = base_name[3:]
|
|
64
|
+
|
|
65
|
+
# Parse pattern: {number}[-{sha}]
|
|
66
|
+
parts = base_name.split("-", 1)
|
|
67
|
+
parsed_pr = int(parts[0])
|
|
68
|
+
|
|
69
|
+
if parsed_pr != pr_number:
|
|
70
|
+
raise ValueError(f"PR number mismatch: expected {pr_number}, got {parsed_pr}")
|
|
71
|
+
|
|
72
|
+
sha = parts[1] if len(parts) > 1 else None
|
|
73
|
+
|
|
74
|
+
return cls(pr_number=pr_number, sha=sha)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def base_name(self) -> str:
|
|
78
|
+
"""Get base name without -service suffix (e.g., pr-34868-abc123f)"""
|
|
79
|
+
if self.sha:
|
|
80
|
+
return f"pr-{self.pr_number}-{self.sha}"
|
|
81
|
+
return f"pr-{self.pr_number}"
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def service_name(self) -> str:
|
|
85
|
+
"""Get full ECS service name (e.g., pr-34868-abc123f-service)"""
|
|
86
|
+
return f"{self.base_name}-service"
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def image_tag(self) -> str:
|
|
90
|
+
"""Get Docker image tag (e.g., pr-34868-abc123f-ci)"""
|
|
91
|
+
if not self.sha:
|
|
92
|
+
raise ValueError("SHA is required for image tag")
|
|
93
|
+
return f"{self.base_name}-ci"
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def short_sha(self) -> Optional[str]:
|
|
97
|
+
"""Get short SHA (first 7 chars) if available"""
|
|
98
|
+
if self.sha:
|
|
99
|
+
return self.sha[:7]
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def __str__(self) -> str:
|
|
103
|
+
"""String representation returns the full service name"""
|
|
104
|
+
return self.service_name
|
showtime/core/show.py
CHANGED
|
@@ -8,6 +8,8 @@ from dataclasses import dataclass
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
from typing import List, Optional
|
|
10
10
|
|
|
11
|
+
from .constants import DEFAULT_TTL
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
# Import interfaces for singleton access
|
|
13
15
|
# Note: These will be imported when the module loads, creating singletons
|
|
@@ -28,7 +30,7 @@ class Show:
|
|
|
28
30
|
status: str # building, built, deploying, running, updating, failed
|
|
29
31
|
ip: Optional[str] = None # Environment IP address
|
|
30
32
|
created_at: Optional[str] = None # ISO timestamp
|
|
31
|
-
ttl: str =
|
|
33
|
+
ttl: str = DEFAULT_TTL # 24h, 48h, close, etc.
|
|
32
34
|
requested_by: Optional[str] = None # GitHub username
|
|
33
35
|
|
|
34
36
|
@property
|
|
@@ -80,26 +82,38 @@ class Show:
|
|
|
80
82
|
"""Check if environment needs update to latest SHA"""
|
|
81
83
|
return self.sha != latest_sha[:7]
|
|
82
84
|
|
|
85
|
+
@property
|
|
86
|
+
def created_datetime(self) -> Optional[datetime]:
|
|
87
|
+
"""Parse created_at timestamp into datetime object (UTC)"""
|
|
88
|
+
from .date_utils import parse_circus_time
|
|
89
|
+
|
|
90
|
+
if self.created_at is None:
|
|
91
|
+
return None
|
|
92
|
+
return parse_circus_time(self.created_at)
|
|
93
|
+
|
|
83
94
|
def is_expired(self, max_age_hours: int) -> bool:
|
|
84
95
|
"""Check if this environment is expired based on age"""
|
|
85
|
-
|
|
96
|
+
from .date_utils import is_expired
|
|
97
|
+
|
|
98
|
+
if self.created_at is None:
|
|
86
99
|
return False
|
|
100
|
+
return is_expired(self.created_at, max_age_hours)
|
|
87
101
|
|
|
88
|
-
|
|
89
|
-
|
|
102
|
+
def age_display(self) -> str:
|
|
103
|
+
"""Get human-readable age of this environment"""
|
|
104
|
+
from .date_utils import age_display
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
except (ValueError, AttributeError):
|
|
95
|
-
return False # If we can't parse, assume not expired
|
|
106
|
+
if self.created_at is None:
|
|
107
|
+
return "unknown"
|
|
108
|
+
return age_display(self.created_at)
|
|
96
109
|
|
|
97
110
|
def to_circus_labels(self) -> List[str]:
|
|
98
111
|
"""Convert show state to circus tent emoji labels (per-SHA format)"""
|
|
112
|
+
from .date_utils import format_utc_now
|
|
99
113
|
from .emojis import CIRCUS_PREFIX, MEANING_TO_EMOJI
|
|
100
114
|
|
|
101
115
|
if not self.created_at:
|
|
102
|
-
self.created_at =
|
|
116
|
+
self.created_at = format_utc_now()
|
|
103
117
|
|
|
104
118
|
labels = [
|
|
105
119
|
f"{CIRCUS_PREFIX} {self.sha} {MEANING_TO_EMOJI['status']} {self.status}", # SHA-first status
|
|
@@ -142,21 +156,20 @@ class Show:
|
|
|
142
156
|
# Mock successful deployment for dry-run
|
|
143
157
|
self.ip = "52.1.2.3"
|
|
144
158
|
|
|
145
|
-
def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) ->
|
|
159
|
+
def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) -> bool:
|
|
146
160
|
"""Stop this environment (cleanup AWS resources)
|
|
147
161
|
|
|
148
|
-
|
|
149
|
-
|
|
162
|
+
Returns:
|
|
163
|
+
True if successful, False otherwise
|
|
150
164
|
"""
|
|
151
165
|
github, aws = get_interfaces()
|
|
152
166
|
|
|
153
167
|
# Delete AWS resources (pure technical work)
|
|
154
168
|
if not dry_run_aws:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
raise Exception(f"Failed to delete AWS service: {self.aws_service_name}")
|
|
169
|
+
result = aws.delete_environment(self.aws_service_name, self.pr_number)
|
|
170
|
+
return bool(result)
|
|
158
171
|
|
|
159
|
-
#
|
|
172
|
+
return True # Dry run is always "successful"
|
|
160
173
|
|
|
161
174
|
def _build_docker_image(self) -> None:
|
|
162
175
|
"""Build Docker image for this environment"""
|