superset-showtime 0.6.3__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 +264 -49
- showtime/core/service_name.py +104 -0
- showtime/core/show.py +30 -17
- showtime/core/sync_state.py +137 -0
- {superset_showtime-0.6.3.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.3.dist-info/RECORD +0 -17
- {superset_showtime-0.6.3.dist-info → superset_showtime-0.6.6.dist-info}/WHEEL +0 -0
- {superset_showtime-0.6.3.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()
|
|
278
304
|
|
|
279
|
-
#
|
|
280
|
-
|
|
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]
|
|
281
309
|
|
|
282
|
-
#
|
|
283
|
-
|
|
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
|
|
314
|
+
|
|
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
|
|
284
319
|
|
|
285
|
-
|
|
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)
|
|
337
|
+
|
|
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,
|
|
@@ -411,16 +562,25 @@ class PullRequest:
|
|
|
411
562
|
return SyncResult(success=True, action_taken=action_needed, show=new_show)
|
|
412
563
|
|
|
413
564
|
elif action_needed == "destroy_environment":
|
|
565
|
+
# Stop the current environment if it exists
|
|
414
566
|
if self.current_show:
|
|
415
567
|
print(f"🗑️ Destroying environment {self.current_show.sha}...")
|
|
416
|
-
self.current_show.stop(
|
|
417
|
-
|
|
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")
|
|
418
575
|
self._post_cleanup_comment(self.current_show, dry_run_github)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
576
|
+
else:
|
|
577
|
+
print("🗑️ No current environment to destroy")
|
|
578
|
+
|
|
579
|
+
# ALWAYS remove all circus labels for stop trigger, regardless of current_show
|
|
580
|
+
if not dry_run_github:
|
|
581
|
+
self.remove_showtime_labels()
|
|
582
|
+
print("🏷️ GitHub labels cleaned up")
|
|
583
|
+
print("✅ Environment destroyed")
|
|
424
584
|
return SyncResult(success=True, action_taken="destroy_environment")
|
|
425
585
|
|
|
426
586
|
else:
|
|
@@ -441,16 +601,22 @@ class PullRequest:
|
|
|
441
601
|
|
|
442
602
|
def stop_environment(self, **kwargs: Any) -> SyncResult:
|
|
443
603
|
"""Stop current environment (CLI stop command logic)"""
|
|
444
|
-
if not self.current_show:
|
|
445
|
-
return SyncResult(
|
|
446
|
-
success=True, action_taken="no_environment", error="No environment to stop"
|
|
447
|
-
)
|
|
448
|
-
|
|
449
604
|
try:
|
|
450
|
-
|
|
451
|
-
|
|
605
|
+
# Stop the current environment if it exists
|
|
606
|
+
if self.current_show:
|
|
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")
|
|
613
|
+
else:
|
|
614
|
+
print("🗑️ No current environment to destroy")
|
|
615
|
+
|
|
616
|
+
# ALWAYS remove all circus labels for stop command, regardless of current_show
|
|
452
617
|
if not kwargs.get("dry_run_github", False):
|
|
453
618
|
self.remove_showtime_labels()
|
|
619
|
+
print("🏷️ GitHub labels cleaned up")
|
|
454
620
|
return SyncResult(success=True, action_taken="stopped")
|
|
455
621
|
except Exception as e:
|
|
456
622
|
return SyncResult(success=False, action_taken="stop_failed", error=str(e))
|
|
@@ -505,6 +671,7 @@ class PullRequest:
|
|
|
505
671
|
"ttl": show.ttl,
|
|
506
672
|
"requested_by": show.requested_by,
|
|
507
673
|
"created_at": show.created_at,
|
|
674
|
+
"age": show.age_display(), # Add age display
|
|
508
675
|
"aws_service_name": show.aws_service_name,
|
|
509
676
|
"show_type": show_type, # New field for display
|
|
510
677
|
},
|
|
@@ -514,7 +681,7 @@ class PullRequest:
|
|
|
514
681
|
return all_environments
|
|
515
682
|
|
|
516
683
|
def _determine_action(self, target_sha: str) -> str:
|
|
517
|
-
"""Determine what sync action is needed
|
|
684
|
+
"""Determine what sync action is needed (includes all checks and refreshes labels)"""
|
|
518
685
|
# CRITICAL: Get fresh labels before any decisions
|
|
519
686
|
self.refresh_labels()
|
|
520
687
|
|
|
@@ -523,7 +690,8 @@ class PullRequest:
|
|
|
523
690
|
return "blocked"
|
|
524
691
|
|
|
525
692
|
# Check authorization (security layer)
|
|
526
|
-
|
|
693
|
+
is_authorized, _ = self._check_authorization()
|
|
694
|
+
if not is_authorized:
|
|
527
695
|
return "blocked"
|
|
528
696
|
|
|
529
697
|
target_sha_short = target_sha[:7] # Ensure we're working with short SHA
|
|
@@ -620,13 +788,16 @@ class PullRequest:
|
|
|
620
788
|
|
|
621
789
|
def _create_new_show(self, target_sha: str) -> Show:
|
|
622
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
|
+
|
|
623
794
|
return Show(
|
|
624
795
|
pr_number=self.pr_number,
|
|
625
796
|
sha=short_sha(target_sha),
|
|
626
797
|
status="building",
|
|
627
|
-
created_at=
|
|
628
|
-
ttl=
|
|
629
|
-
requested_by=
|
|
798
|
+
created_at=format_utc_now(),
|
|
799
|
+
ttl=DEFAULT_TTL,
|
|
800
|
+
requested_by=GitHubInterface.get_current_actor(),
|
|
630
801
|
)
|
|
631
802
|
|
|
632
803
|
def _post_building_comment(self, show: Show, dry_run: bool = False) -> None:
|
|
@@ -699,6 +870,48 @@ class PullRequest:
|
|
|
699
870
|
|
|
700
871
|
return False # Not expired
|
|
701
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
|
+
|
|
702
915
|
@classmethod
|
|
703
916
|
def find_all_with_environments(cls) -> List[int]:
|
|
704
917
|
"""Find all PR numbers that have active environments"""
|
|
@@ -721,9 +934,11 @@ class PullRequest:
|
|
|
721
934
|
|
|
722
935
|
# For running environments, ensure only ONE active pointer exists
|
|
723
936
|
if show.status == "running":
|
|
724
|
-
# Remove ALL existing active pointers
|
|
937
|
+
# Remove ALL existing active pointers EXCEPT for this SHA's pointer
|
|
725
938
|
existing_active_pointers = [
|
|
726
|
-
label
|
|
939
|
+
label
|
|
940
|
+
for label in self.labels
|
|
941
|
+
if label.startswith("🎪 🎯 ") and label != f"🎪 🎯 {show.sha}"
|
|
727
942
|
]
|
|
728
943
|
for old_pointer in existing_active_pointers:
|
|
729
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"""
|