superset-showtime 0.1.0__py3-none-any.whl → 0.2.3__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/core/aws.py CHANGED
@@ -56,6 +56,8 @@ class AWSInterface:
56
56
  sha: str,
57
57
  github_user: str = "unknown",
58
58
  feature_flags: List[Dict[str, str]] = None,
59
+ image_tag_override: Optional[str] = None,
60
+ force: bool = False,
59
61
  ) -> EnvironmentResult:
60
62
  """
61
63
  Create ephemeral environment with blue-green deployment support
@@ -81,20 +83,45 @@ class AWSInterface:
81
83
  requested_by=github_user,
82
84
  )
83
85
 
84
- service_name = f"{show.aws_service_name}-service" # pr-{pr_number}-{sha}-service
85
- image_tag = show.aws_image_tag # pr-{pr_number}-{sha}-ci
86
+ service_name = show.ecs_service_name # pr-{pr_number}-{sha}-service
86
87
 
87
88
  try:
88
- # Step 1: Check if ECR image exists (replicate GHA check-image step)
89
- if not self._check_ecr_image_exists(image_tag):
90
- return EnvironmentResult(
91
- success=False,
92
- error=f"Container image {image_tag} not found in ECR. Build the image first.",
89
+ # Handle force flag - delete existing service for this SHA first
90
+ if force:
91
+ print(f"🗑️ Force flag: Checking for existing service {service_name}")
92
+ if self._service_exists(service_name):
93
+ print(f"🗑️ Deleting existing service: {service_name}")
94
+ success = self._delete_ecs_service(service_name)
95
+ if success:
96
+ print("✅ Service deletion initiated, waiting for completion...")
97
+ # Wait for service to be fully deleted before proceeding
98
+ if self._wait_for_service_deletion(service_name):
99
+ print("✅ Service deletion completed, proceeding with fresh deployment")
100
+ else:
101
+ print("⚠️ Service deletion timeout, proceeding anyway")
102
+ else:
103
+ print("⚠️ Failed to delete existing service, proceeding anyway")
104
+ else:
105
+ print("ℹ️ No existing service found, proceeding with new deployment")
106
+ # Step 1: Determine which Docker image to use (DockerHub direct)
107
+ if image_tag_override:
108
+ # Use explicit override (can be any format)
109
+ docker_image = f"apache/superset:{image_tag_override}"
110
+ print(f"✅ Using override image: {docker_image}")
111
+ else:
112
+ # Use supersetbot PR-SHA format (what supersetbot creates)
113
+ supersetbot_tag = show.aws_image_tag # pr-{pr_number}-{sha}-ci
114
+ docker_image = f"apache/superset:{supersetbot_tag}"
115
+ print(f"✅ Using DockerHub image: {docker_image} (supersetbot PR-SHA format)")
116
+ print(
117
+ "💡 To test with different image: --image-tag latest or --image-tag pr-34639-9a82c20-ci"
93
118
  )
94
119
 
120
+ # Note: No ECR image check needed - ECS will pull from DockerHub directly
121
+
95
122
  # Step 2: Create/update ECS task definition with feature flags
96
123
  task_def_arn = self._create_task_definition_with_image_and_flags(
97
- image_tag, feature_flags or []
124
+ docker_image, feature_flags or []
98
125
  )
99
126
  if not task_def_arn:
100
127
  return EnvironmentResult(success=False, error="Failed to create task definition")
@@ -128,9 +155,9 @@ class AWSInterface:
128
155
  if not self._wait_for_service_stability(service_name):
129
156
  return EnvironmentResult(success=False, error="Service failed to become stable")
130
157
 
131
- # Step 7: Health check the new service
158
+ # Step 7: Health check the new service (longer timeout for Superset + examples)
132
159
  print(f"🏥 Health checking service {service_name}...")
133
- if not self._health_check_service(service_name):
160
+ if not self._health_check_service(service_name, max_attempts=20): # 10 minutes total
134
161
  return EnvironmentResult(success=False, error="Service failed health checks")
135
162
 
136
163
  # Step 8: Get IP after health checks pass
@@ -175,7 +202,9 @@ class AWSInterface:
175
202
  return True
176
203
 
177
204
  except Exception as e:
178
- raise AWSError(message=str(e), operation="delete_environment", resource=service_name)
205
+ raise AWSError(
206
+ message=str(e), operation="delete_environment", resource=service_name
207
+ ) from e
179
208
 
180
209
  def get_environment_ip(self, service_name: str) -> Optional[str]:
181
210
  """
@@ -284,23 +313,18 @@ class AWSInterface:
284
313
  return False
285
314
 
286
315
  def _create_task_definition_with_image_and_flags(
287
- self, image_tag: str, feature_flags: List[Dict[str, str]]
316
+ self, docker_image: str, feature_flags: List[Dict[str, str]]
288
317
  ) -> Optional[str]:
289
- """Create ECS task definition with image and feature flags (replicate GHA task-def + env vars)"""
318
+ """Create ECS task definition with DockerHub image and feature flags"""
290
319
  try:
291
320
  # Load base task definition template
292
321
  task_def_path = Path(__file__).parent.parent / "data" / "ecs-task-definition.json"
293
322
  with open(task_def_path) as f:
294
323
  task_def = json.load(f)
295
324
 
296
- # Get ECR registry for full image URL
297
- ecr_response = self.ecr_client.get_authorization_token()
298
- registry_url = ecr_response["authorizationData"][0]["proxyEndpoint"]
299
- registry_url = registry_url.replace("https://", "")
300
- full_image_url = f"{registry_url}/{self.repository}:{image_tag}"
301
-
302
- # Update image in container definition (replicate GHA render-task-definition)
303
- task_def["containerDefinitions"][0]["image"] = full_image_url
325
+ # Use DockerHub image directly (no ECR needed)
326
+ # docker_image is already in format: apache/superset:abc123f-ci
327
+ task_def["containerDefinitions"][0]["image"] = docker_image
304
328
 
305
329
  # Add feature flags to environment (replicate GHA jq environment update)
306
330
  container_env = task_def["containerDefinitions"][0]["environment"]
@@ -353,7 +377,7 @@ class AWSInterface:
353
377
  """Create ECS service (replicate exact GHA create-service step)"""
354
378
  try:
355
379
  # Replicate exact GHA create-service command parameters
356
- response = self.ecs_client.create_service(
380
+ self.ecs_client.create_service(
357
381
  cluster=self.cluster,
358
382
  serviceName=service_name, # pr-{pr_number}-service
359
383
  taskDefinition=self.cluster, # Uses cluster name as task def family
@@ -469,7 +493,7 @@ class AWSInterface:
469
493
  return orphaned
470
494
 
471
495
  except Exception as e:
472
- raise AWSError(message=str(e), operation="cleanup_orphaned_environments")
496
+ raise AWSError(message=str(e), operation="cleanup_orphaned_environments") from e
473
497
 
474
498
  def update_feature_flags(self, service_name: str, feature_flags: Dict[str, bool]) -> bool:
475
499
  """Update feature flags in running environment"""
@@ -582,7 +606,7 @@ class AWSInterface:
582
606
  if time_match.group(2) == "d":
583
607
  hours *= 24
584
608
 
585
- cutoff_timestamp = time.time() - (hours * 3600)
609
+ # cutoff_timestamp = time.time() - (hours * 3600) # Not used in current implementation
586
610
  expired_services = []
587
611
 
588
612
  # List all services in cluster
@@ -756,3 +780,28 @@ class AWSInterface:
756
780
  except Exception as e:
757
781
  print(f"❌ Health check error: {e}")
758
782
  return False
783
+
784
+ def _wait_for_service_deletion(self, service_name: str, timeout_minutes: int = 5) -> bool:
785
+ """Wait for ECS service to be fully deleted"""
786
+ import time
787
+
788
+ try:
789
+ max_attempts = timeout_minutes * 12 # 5s intervals
790
+
791
+ for attempt in range(max_attempts):
792
+ # Check if service still exists
793
+ if not self._service_exists(service_name):
794
+ print(f"✅ Service {service_name} fully deleted after {attempt * 5}s")
795
+ return True
796
+
797
+ if attempt % 6 == 0: # Every 30s
798
+ print(f"⏳ Waiting for service deletion... ({attempt * 5}s elapsed)")
799
+
800
+ time.sleep(5) # Check every 5 seconds
801
+
802
+ print(f"⚠️ Service deletion timeout after {timeout_minutes} minutes")
803
+ return False
804
+
805
+ except Exception as e:
806
+ print(f"❌ Error waiting for service deletion: {e}")
807
+ return False
showtime/core/circus.py CHANGED
@@ -23,7 +23,6 @@ class Show:
23
23
  created_at: Optional[str] = None # ISO timestamp
24
24
  ttl: str = "24h" # 24h, 48h, close, etc.
25
25
  requested_by: Optional[str] = None # GitHub username
26
- config: str = "standard" # Configuration (alerts,debug)
27
26
 
28
27
  @property
29
28
  def aws_service_name(self) -> str:
@@ -35,6 +34,11 @@ class Show:
35
34
  """Deterministic ECR image tag: pr-{pr_number}-{sha}-ci"""
36
35
  return f"pr-{self.pr_number}-{self.sha}-ci"
37
36
 
37
+ @property
38
+ def ecs_service_name(self) -> str:
39
+ """Deterministic ECS service name with -service suffix: pr-{pr_number}-{sha}-service"""
40
+ return f"{self.aws_service_name}-service"
41
+
38
42
  @property
39
43
  def is_active(self) -> bool:
40
44
  """Check if this is the currently active show"""
@@ -72,9 +76,6 @@ class Show:
72
76
  if self.requested_by:
73
77
  labels.append(f"🎪 {self.sha} 🤡 {self.requested_by}")
74
78
 
75
- if self.config != "standard":
76
- labels.append(f"🎪 {self.sha} ⚙️ {self.config}")
77
-
78
79
  return labels
79
80
 
80
81
  @classmethod
@@ -112,8 +113,6 @@ class Show:
112
113
  show_data["ttl"] = value
113
114
  elif emoji == "🤡": # User (clown!)
114
115
  show_data["requested_by"] = value
115
- elif emoji == "⚙️": # Config
116
- show_data["config"] = value
117
116
 
118
117
  # Only return Show if we found relevant labels for this SHA
119
118
  if any(label.endswith(f" {sha}") for label in labels if "🎯" in label or "🏗️" in label):
@@ -220,66 +219,51 @@ class PullRequest:
220
219
  self._shows = self._parse_shows_from_labels()
221
220
 
222
221
 
223
- # Utility functions for configuration label handling
224
- def is_configuration_label(label: str) -> bool:
225
- """Check if label is a configuration command"""
226
- return label.startswith("🎪 conf-")
227
-
228
-
229
- def parse_configuration_command(label: str) -> Optional[str]:
230
- """
231
- Parse configuration command from label
232
-
233
- Args:
234
- label: Label like "🎪 conf-enable-ALERTS"
235
-
236
- Returns:
237
- Command string like "enable-ALERTS" or None if not a config label
238
- """
239
- if not is_configuration_label(label):
240
- return None
222
+ def parse_ttl_days(ttl_str: str) -> Optional[float]:
223
+ """Parse TTL string to days"""
224
+ import re
241
225
 
242
- return label.replace("🎪 conf-", "")
226
+ if ttl_str == "never":
227
+ return None # Never expire
243
228
 
229
+ if ttl_str == "close":
230
+ return None # Special handling needed
244
231
 
245
- def merge_config(current_config: str, command: str) -> str:
246
- """
247
- Merge new configuration command into existing config
232
+ # Parse number + unit: 24h, 7d, 2w, etc.
233
+ match = re.match(r"(\d+(?:\.\d+)?)(h|d|w)", ttl_str.lower())
234
+ if not match:
235
+ return 2.0 # Default 2 days if invalid
248
236
 
249
- Args:
250
- current_config: Current config string like "standard,alerts"
251
- command: New command like "enable-DASHBOARD_RBAC" or "disable-ALERTS"
237
+ value = float(match.group(1))
238
+ unit = match.group(2)
252
239
 
253
- Returns:
254
- Updated config string
255
- """
256
- configs = current_config.split(",") if current_config != "standard" else []
240
+ if unit == "h":
241
+ return value / 24.0 # Hours to days
242
+ elif unit == "d":
243
+ return value # Already in days
244
+ elif unit == "w":
245
+ return value * 7.0 # Weeks to days
257
246
 
258
- # Handle feature flag commands
259
- if command.startswith("enable-"):
260
- feature = command.replace("enable-", "").lower()
261
- configs = [c for c in configs if not c.startswith(f"no-{feature}")]
262
- configs.append(feature)
247
+ return 2.0 # Default
263
248
 
264
- elif command.startswith("disable-"):
265
- feature = command.replace("disable-", "").lower()
266
- configs = [c for c in configs if c != feature]
267
- configs.append(f"no-{feature}")
268
249
 
269
- # Handle debug toggle commands
270
- elif command == "debug-on":
271
- configs = [c for c in configs if c != "debug"]
272
- configs.append("debug")
250
+ def get_effective_ttl(pr) -> Optional[float]:
251
+ """Get effective TTL in days for a PR (handles multiple labels, conflicts)"""
252
+ ttl_labels = []
273
253
 
274
- elif command == "debug-off":
275
- configs = [c for c in configs if c != "debug"]
254
+ # Find all TTL labels for all shows
255
+ for show in pr.shows:
256
+ if show.ttl:
257
+ ttl_days = parse_ttl_days(show.ttl)
258
+ if ttl_days is None: # "never" or "close"
259
+ if show.ttl == "never":
260
+ return None # Never expire takes precedence
261
+ # For "close", continue checking others
262
+ else:
263
+ ttl_labels.append(ttl_days)
276
264
 
277
- # Handle size commands
278
- elif command.startswith("size-"):
279
- size = command # Keep full size command
280
- configs = [c for c in configs if not c.startswith("size-")]
281
- configs.append(size)
265
+ if not ttl_labels:
266
+ return 2.0 # Default 2 days
282
267
 
283
- # Return cleaned config
284
- unique_configs = list(dict.fromkeys(configs)) # Remove duplicates, preserve order
285
- return ",".join(unique_configs) if unique_configs else "standard"
268
+ # Use longest duration if multiple labels
269
+ return max(ttl_labels)
showtime/core/emojis.py CHANGED
@@ -18,7 +18,6 @@ EMOJI_MEANINGS = {
18
18
  "🌐": "ip", # Globe for IP address
19
19
  "⌛": "ttl", # Hourglass for time-to-live
20
20
  "🤡": "requested_by", # Clown for who requested (circus theme!)
21
- "⚙️": "config", # Gear for configuration
22
21
  }
23
22
 
24
23
  # Reverse mapping for creating labels
@@ -30,13 +29,9 @@ STATUS_DISPLAY = {
30
29
  "running": "🟢",
31
30
  "updating": "🔄",
32
31
  "failed": "❌",
33
- "configuring": "⚙️",
34
32
  "stopping": "🛑",
35
33
  }
36
34
 
37
- # Configuration command prefixes
38
- CONFIG_PREFIX = f"{CIRCUS_PREFIX} conf-"
39
-
40
35
 
41
36
  def create_circus_label(emoji_key: str, value: str) -> str:
42
37
  """Create a circus tent label with proper spacing"""
@@ -79,8 +74,3 @@ def parse_circus_label(label: str) -> tuple[str, str]:
79
74
  def is_circus_label(label: str) -> bool:
80
75
  """Check if label is a circus tent label"""
81
76
  return label.startswith(f"{CIRCUS_PREFIX} ")
82
-
83
-
84
- def is_configuration_command(label: str) -> bool:
85
- """Check if label is a configuration command"""
86
- return label.startswith(CONFIG_PREFIX)
showtime/core/github.py CHANGED
@@ -72,13 +72,29 @@ class GitHubInterface:
72
72
  return [label["name"] for label in labels_data]
73
73
 
74
74
  def add_label(self, pr_number: int, label: str) -> None:
75
- """Add a label to a PR"""
75
+ """Add a label to a PR (automatically creates label definition if needed)"""
76
+
77
+ # Ensure label definition exists with proper color/description
78
+ self._ensure_label_definition_exists(label)
79
+
76
80
  url = f"{self.base_url}/repos/{self.org}/{self.repo}/issues/{pr_number}/labels"
77
81
 
78
82
  with httpx.Client() as client:
79
83
  response = client.post(url, headers=self.headers, json={"labels": [label]})
80
84
  response.raise_for_status()
81
85
 
86
+ def _ensure_label_definition_exists(self, label: str) -> None:
87
+ """Ensure label definition exists in repository with proper color/description"""
88
+ from .label_colors import get_label_color, get_label_description
89
+
90
+ try:
91
+ color = get_label_color(label)
92
+ description = get_label_description(label)
93
+ self.create_or_update_label(label, color, description)
94
+ except Exception:
95
+ # If label creation fails, continue anyway - label might already exist
96
+ pass
97
+
82
98
  def remove_label(self, pr_number: int, label: str) -> None:
83
99
  """Remove a label from a PR"""
84
100
  # URL encode the label name for special characters like emojis
@@ -212,3 +228,29 @@ class GitHubInterface:
212
228
  return deleted_labels
213
229
 
214
230
  return sha_labels
231
+
232
+ def create_or_update_label(self, name: str, color: str, description: str) -> bool:
233
+ """Create or update a label with color and description"""
234
+ import urllib.parse
235
+
236
+ # Check if label exists
237
+ encoded_name = urllib.parse.quote(name, safe="")
238
+ url = f"{self.base_url}/repos/{self.org}/{self.repo}/labels/{encoded_name}"
239
+
240
+ label_data = {"name": name, "color": color, "description": description}
241
+
242
+ with httpx.Client() as client:
243
+ # Try to update first (if exists)
244
+ response = client.patch(url, headers=self.headers, json=label_data)
245
+
246
+ if response.status_code == 200:
247
+ return False # Updated existing
248
+ elif response.status_code == 404:
249
+ # Label doesn't exist, create it
250
+ create_url = f"{self.base_url}/repos/{self.org}/{self.repo}/labels"
251
+ response = client.post(create_url, headers=self.headers, json=label_data)
252
+ response.raise_for_status()
253
+ return True # Created new
254
+ else:
255
+ response.raise_for_status()
256
+ return False
@@ -0,0 +1,105 @@
1
+ """
2
+ 🎪 Circus tent label color scheme and definitions
3
+
4
+ Centralized color map for all GitHub labels with descriptions.
5
+ """
6
+
7
+ # Color Palette - Bright Yellow Circus Theme
8
+ COLORS = {
9
+ # Theme Colors
10
+ "circus_yellow": "FFD700", # Bright yellow - primary circus theme
11
+ "metadata_yellow": "FFF9C4", # Light yellow - metadata labels
12
+ # Status Colors (Semantic)
13
+ "status_running": "28a745", # Green - healthy/running
14
+ "status_building": "FFD700", # Bright yellow - in progress
15
+ "status_failed": "dc3545", # Red - error/failed
16
+ "status_updating": "fd7e14", # Orange - updating/transitioning
17
+ }
18
+
19
+ # Label Definitions with Colors and Descriptions
20
+ LABEL_DEFINITIONS = {
21
+ # Action/Trigger Labels (Bright Yellow - User-facing, namespaced)
22
+ "🎪 ⚡ showtime-trigger-start": {
23
+ "color": COLORS["circus_yellow"],
24
+ "description": "Create new ephemeral environment for this PR",
25
+ },
26
+ "🎪 🛑 showtime-trigger-stop": {
27
+ "color": COLORS["circus_yellow"],
28
+ "description": "Destroy ephemeral environment and clean up AWS resources",
29
+ },
30
+ "🎪 🧊 showtime-freeze": {
31
+ "color": "FFE4B5", # Light orange
32
+ "description": "Freeze PR - prevent auto-sync on new commits",
33
+ },
34
+ }
35
+
36
+ # Status-specific label patterns (generated dynamically)
37
+ STATUS_LABEL_COLORS = {
38
+ "running": COLORS["status_running"], # 🎪 abc123f 🚦 running
39
+ "building": COLORS["status_building"], # 🎪 abc123f 🚦 building
40
+ "failed": COLORS["status_failed"], # 🎪 abc123f 🚦 failed
41
+ "updating": COLORS["status_updating"], # 🎪 abc123f 🚦 updating
42
+ }
43
+
44
+ # Metadata label color (for all other circus tent labels)
45
+ METADATA_LABEL_COLOR = COLORS["metadata_yellow"] # 🎪 abc123f 📅 ..., 🎪 abc123f 🌐 ..., etc.
46
+
47
+
48
+ def get_label_color(label_text: str) -> str:
49
+ """Get appropriate color for any circus tent label"""
50
+
51
+ # Check for exact matches in definitions
52
+ if label_text in LABEL_DEFINITIONS:
53
+ return LABEL_DEFINITIONS[label_text]["color"]
54
+
55
+ # Check for status labels with dynamic SHA
56
+ if " 🚦 " in label_text:
57
+ status = label_text.split(" 🚦 ")[-1]
58
+ return STATUS_LABEL_COLORS.get(status, COLORS["circus_yellow"])
59
+
60
+ # All other metadata labels (timestamps, IPs, TTL, users, pointers)
61
+ if label_text.startswith("🎪 "):
62
+ return METADATA_LABEL_COLOR
63
+
64
+ # Fallback
65
+ return COLORS["circus_yellow"]
66
+
67
+
68
+ def get_label_description(label_text: str) -> str:
69
+ """Get appropriate description for any circus tent label"""
70
+
71
+ # Check for exact matches
72
+ if label_text in LABEL_DEFINITIONS:
73
+ return LABEL_DEFINITIONS[label_text]["description"]
74
+
75
+ # Dynamic descriptions for SHA-based labels
76
+ if " 🚦 " in label_text:
77
+ sha, status = label_text.replace("🎪 ", "").split(" 🚦 ")
78
+ return f"Environment {sha} status: {status}"
79
+
80
+ if " 📅 " in label_text:
81
+ sha, timestamp = label_text.replace("🎪 ", "").split(" 📅 ")
82
+ return f"Environment {sha} created at {timestamp}"
83
+
84
+ if " 🌐 " in label_text:
85
+ sha, url = label_text.replace("🎪 ", "").split(" 🌐 ")
86
+ return f"Environment {sha} URL: http://{url} (click to visit)"
87
+
88
+ if " ⌛ " in label_text:
89
+ sha, ttl = label_text.replace("🎪 ", "").split(" ⌛ ")
90
+ return f"Environment {sha} expires after {ttl}"
91
+
92
+ if " 🤡 " in label_text:
93
+ sha, user = label_text.replace("🎪 ", "").split(" 🤡 ")
94
+ return f"Environment {sha} requested by {user}"
95
+
96
+ if "🎪 🎯 " in label_text:
97
+ sha = label_text.replace("🎪 🎯 ", "")
98
+ return f"Active environment pointer - {sha} is receiving traffic"
99
+
100
+ if "🎪 🏗️ " in label_text:
101
+ sha = label_text.replace("🎪 🏗️ ", "")
102
+ return f"Building environment - {sha} deployment in progress"
103
+
104
+ # Fallback
105
+ return "Circus tent showtime label"
@@ -31,6 +31,10 @@
31
31
  {
32
32
  "name": "TALISMAN_ENABLED",
33
33
  "value": "False"
34
+ },
35
+ {
36
+ "name": "SUPERSET__SQLALCHEMY_EXAMPLES_URI",
37
+ "value": "duckdb:////app/data/examples.duckdb"
34
38
  }
35
39
  ],
36
40
  "mountPoints": [],