superset-showtime 0.3.3__py3-none-any.whl → 0.4.0__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 +3 -2
- showtime/cli.py +268 -1523
- showtime/core/aws.py +30 -26
- showtime/core/github.py +10 -6
- showtime/core/github_messages.py +4 -4
- showtime/core/pull_request.py +534 -0
- showtime/core/show.py +279 -0
- {superset_showtime-0.3.3.dist-info → superset_showtime-0.4.0.dist-info}/METADATA +17 -9
- superset_showtime-0.4.0.dist-info/RECORD +16 -0
- showtime/commands/__init__.py +0 -1
- showtime/commands/start.py +0 -40
- showtime/core/circus.py +0 -289
- superset_showtime-0.3.3.dist-info/RECORD +0 -17
- {superset_showtime-0.3.3.dist-info → superset_showtime-0.4.0.dist-info}/WHEEL +0 -0
- {superset_showtime-0.3.3.dist-info → superset_showtime-0.4.0.dist-info}/entry_points.txt +0 -0
showtime/core/show.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
🎪 Show class - Individual ephemeral environment management
|
|
3
|
+
|
|
4
|
+
Single environment operations: Docker build, AWS deployment, state transitions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Import interfaces for singleton access
|
|
13
|
+
# Note: These will be imported when the module loads, creating singletons
|
|
14
|
+
def get_interfaces(): # type: ignore
|
|
15
|
+
"""Lazy-load interfaces to avoid circular imports"""
|
|
16
|
+
from .aws import AWSInterface
|
|
17
|
+
from .github import GitHubInterface
|
|
18
|
+
|
|
19
|
+
return GitHubInterface(), AWSInterface()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Show:
|
|
24
|
+
"""Single ephemeral environment state from circus labels"""
|
|
25
|
+
|
|
26
|
+
pr_number: int
|
|
27
|
+
sha: str # 7-char commit SHA
|
|
28
|
+
status: str # building, built, deploying, running, updating, failed
|
|
29
|
+
ip: Optional[str] = None # Environment IP address
|
|
30
|
+
created_at: Optional[str] = None # ISO timestamp
|
|
31
|
+
ttl: str = "24h" # 24h, 48h, close, etc.
|
|
32
|
+
requested_by: Optional[str] = None # GitHub username
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def aws_service_name(self) -> str:
|
|
36
|
+
"""Deterministic ECS service name: pr-{pr_number}-{sha}"""
|
|
37
|
+
return f"pr-{self.pr_number}-{self.sha}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ecs_service_name(self) -> str:
|
|
41
|
+
"""ECS service name with -service suffix"""
|
|
42
|
+
return f"{self.aws_service_name}-service"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def aws_image_tag(self) -> str:
|
|
46
|
+
"""Deterministic Docker image tag: pr-{pr_number}-{sha}-ci"""
|
|
47
|
+
return f"pr-{self.pr_number}-{self.sha}-ci"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def short_sha(self) -> str:
|
|
51
|
+
"""Return the short SHA (already short)"""
|
|
52
|
+
return self.sha
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_running(self) -> bool:
|
|
56
|
+
"""Check if environment is currently running"""
|
|
57
|
+
return self.status == "running"
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_building(self) -> bool:
|
|
61
|
+
"""Check if environment is currently building"""
|
|
62
|
+
return self.status == "building"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def is_built(self) -> bool:
|
|
66
|
+
"""Check if environment is built (Docker complete, ready for deploy)"""
|
|
67
|
+
return self.status == "built"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_deploying(self) -> bool:
|
|
71
|
+
"""Check if environment is currently deploying to AWS"""
|
|
72
|
+
return self.status == "deploying"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_updating(self) -> bool:
|
|
76
|
+
"""Check if environment is currently updating"""
|
|
77
|
+
return self.status == "updating"
|
|
78
|
+
|
|
79
|
+
def needs_update(self, latest_sha: str) -> bool:
|
|
80
|
+
"""Check if environment needs update to latest SHA"""
|
|
81
|
+
return self.sha != latest_sha[:7]
|
|
82
|
+
|
|
83
|
+
def is_expired(self, max_age_hours: int) -> bool:
|
|
84
|
+
"""Check if this environment is expired based on age"""
|
|
85
|
+
if not self.created_at:
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
from datetime import datetime, timedelta
|
|
90
|
+
|
|
91
|
+
created_time = datetime.fromisoformat(self.created_at.replace("-", ":"))
|
|
92
|
+
expiry_time = created_time + timedelta(hours=max_age_hours)
|
|
93
|
+
return datetime.now() > expiry_time
|
|
94
|
+
except (ValueError, AttributeError):
|
|
95
|
+
return False # If we can't parse, assume not expired
|
|
96
|
+
|
|
97
|
+
def to_circus_labels(self) -> List[str]:
|
|
98
|
+
"""Convert show state to circus tent emoji labels (per-SHA format)"""
|
|
99
|
+
if not self.created_at:
|
|
100
|
+
self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
|
|
101
|
+
|
|
102
|
+
labels = [
|
|
103
|
+
f"🎪 {self.sha} 🚦 {self.status}", # SHA-first status
|
|
104
|
+
f"🎪 🎯 {self.sha}", # Active pointer (no value)
|
|
105
|
+
f"🎪 {self.sha} 📅 {self.created_at}", # SHA-first timestamp
|
|
106
|
+
f"🎪 {self.sha} ⌛ {self.ttl}", # SHA-first TTL
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
if self.ip:
|
|
110
|
+
labels.append(f"🎪 {self.sha} 🌐 {self.ip}:8080")
|
|
111
|
+
|
|
112
|
+
if self.requested_by:
|
|
113
|
+
labels.append(f"🎪 {self.sha} 🤡 {self.requested_by}")
|
|
114
|
+
|
|
115
|
+
return labels
|
|
116
|
+
|
|
117
|
+
def build_docker(self, dry_run: bool = False) -> None:
|
|
118
|
+
"""Build Docker image for this environment (atomic operation)"""
|
|
119
|
+
if not dry_run:
|
|
120
|
+
self._build_docker_image() # Raises on failure
|
|
121
|
+
|
|
122
|
+
def deploy_aws(self, dry_run: bool = False) -> None:
|
|
123
|
+
"""Deploy to AWS (atomic operation)"""
|
|
124
|
+
github, aws = get_interfaces()
|
|
125
|
+
|
|
126
|
+
if not dry_run:
|
|
127
|
+
result = aws.create_environment(
|
|
128
|
+
pr_number=self.pr_number,
|
|
129
|
+
sha=self.sha + "0" * (40 - len(self.sha)), # Convert to full SHA
|
|
130
|
+
github_user=self.requested_by or "unknown",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if not result.success:
|
|
134
|
+
raise Exception(f"AWS deployment failed: {result.error}")
|
|
135
|
+
|
|
136
|
+
# Update with deployment results
|
|
137
|
+
self.ip = result.ip
|
|
138
|
+
else:
|
|
139
|
+
# Mock successful deployment for dry-run
|
|
140
|
+
self.ip = "52.1.2.3"
|
|
141
|
+
|
|
142
|
+
def stop(self, dry_run_github: bool = False, dry_run_aws: bool = False) -> None:
|
|
143
|
+
"""Stop this environment (cleanup AWS resources)
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
Exception: On cleanup failure
|
|
147
|
+
"""
|
|
148
|
+
github, aws = get_interfaces()
|
|
149
|
+
|
|
150
|
+
# Delete AWS resources (pure technical work)
|
|
151
|
+
if not dry_run_aws:
|
|
152
|
+
success = aws.delete_environment(self.aws_service_name, self.pr_number)
|
|
153
|
+
if not success:
|
|
154
|
+
raise Exception(f"Failed to delete AWS service: {self.aws_service_name}")
|
|
155
|
+
|
|
156
|
+
# No comments - PullRequest handles that!
|
|
157
|
+
|
|
158
|
+
def _build_docker_image(self) -> None:
|
|
159
|
+
"""Build Docker image for this environment"""
|
|
160
|
+
import os
|
|
161
|
+
import platform
|
|
162
|
+
import subprocess
|
|
163
|
+
|
|
164
|
+
tag = f"apache/superset:pr-{self.pr_number}-{self.sha}-ci"
|
|
165
|
+
|
|
166
|
+
# Detect if running in CI environment
|
|
167
|
+
is_ci = bool(os.getenv("GITHUB_ACTIONS") or os.getenv("CI"))
|
|
168
|
+
|
|
169
|
+
# Base command
|
|
170
|
+
cmd = [
|
|
171
|
+
"docker",
|
|
172
|
+
"buildx",
|
|
173
|
+
"build",
|
|
174
|
+
"--push",
|
|
175
|
+
"--platform",
|
|
176
|
+
"linux/amd64",
|
|
177
|
+
"--target",
|
|
178
|
+
"ci",
|
|
179
|
+
"--build-arg",
|
|
180
|
+
"INCLUDE_CHROMIUM=false",
|
|
181
|
+
"--build-arg",
|
|
182
|
+
"LOAD_EXAMPLES_DUCKDB=true",
|
|
183
|
+
"-t",
|
|
184
|
+
tag,
|
|
185
|
+
".",
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
# Add caching based on environment
|
|
189
|
+
if is_ci:
|
|
190
|
+
# Full registry caching in CI (Docker driver supports it)
|
|
191
|
+
cmd.extend([
|
|
192
|
+
"--cache-from",
|
|
193
|
+
"type=registry,ref=apache/superset-cache:3.10-slim-bookworm",
|
|
194
|
+
"--cache-to",
|
|
195
|
+
"type=registry,mode=max,ref=apache/superset-cache:3.10-slim-bookworm",
|
|
196
|
+
])
|
|
197
|
+
print("🐳 CI environment: Using full registry caching")
|
|
198
|
+
else:
|
|
199
|
+
# Local build: cache-from only (no cache export)
|
|
200
|
+
cmd.extend([
|
|
201
|
+
"--cache-from",
|
|
202
|
+
"type=registry,ref=apache/superset-cache:3.10-slim-bookworm",
|
|
203
|
+
])
|
|
204
|
+
print("🐳 Local environment: Using cache-from only (no export)")
|
|
205
|
+
|
|
206
|
+
# Add --load only when building for native architecture or explicitly requested
|
|
207
|
+
# Intel Mac/Linux can load linux/amd64, Apple Silicon cannot
|
|
208
|
+
native_x86 = platform.machine() in ("x86_64", "AMD64")
|
|
209
|
+
force_load = os.getenv("DOCKER_LOAD", "false").lower() == "true"
|
|
210
|
+
|
|
211
|
+
if native_x86 or force_load:
|
|
212
|
+
cmd.insert(-1, "--load") # Insert before the "." argument
|
|
213
|
+
print("🐳 Will load image to local Docker daemon (native x86_64 platform)")
|
|
214
|
+
else:
|
|
215
|
+
print("🐳 Cross-platform build - pushing to registry only (no local load)")
|
|
216
|
+
|
|
217
|
+
print(f"🐳 Building Docker image: {tag}")
|
|
218
|
+
|
|
219
|
+
# Stream output in real-time
|
|
220
|
+
process = subprocess.Popen(
|
|
221
|
+
cmd,
|
|
222
|
+
stdout=subprocess.PIPE,
|
|
223
|
+
stderr=subprocess.STDOUT,
|
|
224
|
+
text=True,
|
|
225
|
+
bufsize=1,
|
|
226
|
+
universal_newlines=True,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
if process.stdout:
|
|
230
|
+
for line in process.stdout:
|
|
231
|
+
print(f"🐳 {line.rstrip()}")
|
|
232
|
+
|
|
233
|
+
return_code = process.wait(timeout=3600)
|
|
234
|
+
if return_code != 0:
|
|
235
|
+
raise Exception(f"Docker build failed with exit code: {return_code}")
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def from_circus_labels(cls, pr_number: int, labels: List[str], sha: str) -> Optional["Show"]:
|
|
239
|
+
"""Create Show from circus tent labels for specific SHA"""
|
|
240
|
+
show_data = {
|
|
241
|
+
"pr_number": pr_number,
|
|
242
|
+
"sha": sha,
|
|
243
|
+
"status": "building", # default
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for label in labels:
|
|
247
|
+
if not label.startswith("🎪"):
|
|
248
|
+
continue
|
|
249
|
+
|
|
250
|
+
parts = label.split(" ")
|
|
251
|
+
if len(parts) < 3:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Per-SHA format: 🎪 {sha} {emoji} {value}
|
|
255
|
+
if parts[1] == sha: # This label is for our SHA
|
|
256
|
+
emoji = parts[2]
|
|
257
|
+
value = " ".join(parts[3:]) if len(parts) > 3 else ""
|
|
258
|
+
|
|
259
|
+
if emoji == "🚦": # Status
|
|
260
|
+
show_data["status"] = value
|
|
261
|
+
elif emoji == "📅": # Timestamp
|
|
262
|
+
show_data["created_at"] = value
|
|
263
|
+
elif emoji == "🌐": # IP with port
|
|
264
|
+
show_data["ip"] = value.replace(":8080", "") # Remove port for storage
|
|
265
|
+
elif emoji == "⌛": # TTL
|
|
266
|
+
show_data["ttl"] = value
|
|
267
|
+
elif emoji == "🤡": # User (clown!)
|
|
268
|
+
show_data["requested_by"] = value
|
|
269
|
+
|
|
270
|
+
# Only return Show if we found relevant labels for this SHA
|
|
271
|
+
if any(label.endswith(f" {sha}") for label in labels if "🎯" in label or "🏗️" in label):
|
|
272
|
+
return cls(**show_data) # type: ignore[arg-type]
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def short_sha(full_sha: str) -> str:
|
|
278
|
+
"""Convert full SHA to short SHA (7 chars)"""
|
|
279
|
+
return full_sha[:7]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: superset-showtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: 🎪 Apache Superset ephemeral environment management with circus tent emoji state tracking
|
|
5
5
|
Project-URL: Homepage, https://github.com/apache/superset-showtime
|
|
6
6
|
Project-URL: Documentation, https://superset-showtime.readthedocs.io/
|
|
@@ -49,6 +49,7 @@ Provides-Extra: azure
|
|
|
49
49
|
Requires-Dist: azure-mgmt-containerinstance>=10.0.0; extra == 'azure'
|
|
50
50
|
Requires-Dist: azure-storage-blob>=12.0.0; extra == 'azure'
|
|
51
51
|
Provides-Extra: dev
|
|
52
|
+
Requires-Dist: boto3-stubs[ec2,ecr,ecs]>=1.40.0; extra == 'dev'
|
|
52
53
|
Requires-Dist: build>=1.0.0; extra == 'dev'
|
|
53
54
|
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
54
55
|
Requires-Dist: pre-commit>=3.0.0; extra == 'dev'
|
|
@@ -157,11 +158,11 @@ showtime labels # Complete label reference
|
|
|
157
158
|
|
|
158
159
|
**Testing/development:**
|
|
159
160
|
```bash
|
|
160
|
-
showtime
|
|
161
|
-
showtime
|
|
161
|
+
showtime sync 1234 --dry-run-aws --dry-run-docker # Test without costs
|
|
162
|
+
showtime cleanup --dry-run --older-than 1h # Test cleanup logic
|
|
162
163
|
```
|
|
163
164
|
|
|
164
|
-
> **
|
|
165
|
+
> **Architecture**: This CLI implements ACID-style atomic transactions with direct Docker integration. It handles complete environment lifecycle from Docker build to AWS deployment with race condition prevention.
|
|
165
166
|
|
|
166
167
|
## 🎪 Complete Label Reference
|
|
167
168
|
|
|
@@ -275,7 +276,14 @@ The CLI is primarily used by GitHub Actions, but available for debugging and adv
|
|
|
275
276
|
```bash
|
|
276
277
|
pip install superset-showtime
|
|
277
278
|
export GITHUB_TOKEN=your_token
|
|
278
|
-
|
|
279
|
+
|
|
280
|
+
# Core commands:
|
|
281
|
+
showtime sync PR_NUMBER # Sync to desired state (main command)
|
|
282
|
+
showtime start PR_NUMBER # Create new environment
|
|
283
|
+
showtime stop PR_NUMBER # Delete environment
|
|
284
|
+
showtime status PR_NUMBER # Show current state
|
|
285
|
+
showtime list # List all environments
|
|
286
|
+
showtime cleanup --older-than 48h # Clean up expired environments
|
|
279
287
|
```
|
|
280
288
|
|
|
281
289
|
|
|
@@ -286,11 +294,11 @@ showtime --help # See all available commands
|
|
|
286
294
|
|
|
287
295
|
**Test with real PRs safely:**
|
|
288
296
|
```bash
|
|
289
|
-
# Test
|
|
290
|
-
showtime
|
|
297
|
+
# Test full workflow without costs:
|
|
298
|
+
showtime sync YOUR_PR_NUMBER --dry-run-aws --dry-run-docker
|
|
291
299
|
|
|
292
|
-
# Test
|
|
293
|
-
showtime
|
|
300
|
+
# Test cleanup logic:
|
|
301
|
+
showtime cleanup --dry-run --older-than 24h
|
|
294
302
|
```
|
|
295
303
|
|
|
296
304
|
### Development Setup
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
showtime/__init__.py,sha256=-VanLnRi-1LQx8ZMbCmCDzf_BpJtn-aM-YVjbXTxW_M,448
|
|
2
|
+
showtime/__main__.py,sha256=EVaDaTX69yIhCzChg99vqvFSCN4ELstEt7Mpb9FMZX8,109
|
|
3
|
+
showtime/cli.py,sha256=DBmGmyAxEFJTPYPYR690iSS8b9QU3tqrc7XFjX3OnRQ,24984
|
|
4
|
+
showtime/core/__init__.py,sha256=54hbdFNGrzuNMBdraezfjT8Zi6g221pKlJ9mREnKwCw,34
|
|
5
|
+
showtime/core/aws.py,sha256=REeZ6_1C9f6mBchBAGa1MeDJeZIwir4IJ92HLRcK5ok,32636
|
|
6
|
+
showtime/core/emojis.py,sha256=MHEDuPIdfNiop4zbNLuviz3eY05QiftYSHHCVbkfKhw,2129
|
|
7
|
+
showtime/core/github.py,sha256=uETvKDO2Yhpqg3fxLtrKaCuZR3b-1LVmgnf5aLcqrAQ,9988
|
|
8
|
+
showtime/core/github_messages.py,sha256=MfgwCukrEsWWesMsuL8saciDgP4nS-gijzu8DXr-Alg,7450
|
|
9
|
+
showtime/core/label_colors.py,sha256=efhbFnz_3nqEnEqmgyF6_hZbxtCu_fmb68BIIUpSsnk,3895
|
|
10
|
+
showtime/core/pull_request.py,sha256=ZYhVFd1INZo_oMR4UDrECjVnpNuVIosmeGUX2n_9-94,20287
|
|
11
|
+
showtime/core/show.py,sha256=BRMH_Z53UfK0VXAMHfzS0u7_s0h634VKTMzWMniRNsg,9759
|
|
12
|
+
showtime/data/ecs-task-definition.json,sha256=0ZaE0FZ8IWduXd2RyscMhXeVgxyym6qtjH02CK9mXBI,2235
|
|
13
|
+
superset_showtime-0.4.0.dist-info/METADATA,sha256=e9HkKNaStiTD8PL9DIiZP2XqQPrFeyoNXBE2zCw3cLY,12052
|
|
14
|
+
superset_showtime-0.4.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
15
|
+
superset_showtime-0.4.0.dist-info/entry_points.txt,sha256=rDW7oZ57mqyBUS4N_3_R7bZNGVHB-104jwmY-hHC_ck,85
|
|
16
|
+
superset_showtime-0.4.0.dist-info/RECORD,,
|
showtime/commands/__init__.py
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Showtime CLI commands"""
|
showtime/commands/start.py
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
🎪 Start show command - Create ephemeral environments
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from typing import Optional
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@dataclass
|
|
10
|
-
class StartResult:
|
|
11
|
-
"""Result of starting a show"""
|
|
12
|
-
|
|
13
|
-
success: bool
|
|
14
|
-
url: Optional[str] = None
|
|
15
|
-
error: Optional[str] = None
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def start_show(
|
|
19
|
-
pr_number: int, sha: Optional[str] = None, ttl: str = "24h", size: str = "standard"
|
|
20
|
-
) -> StartResult:
|
|
21
|
-
"""
|
|
22
|
-
Start the show! Create ephemeral environment for PR
|
|
23
|
-
|
|
24
|
-
Args:
|
|
25
|
-
pr_number: PR number to create environment for
|
|
26
|
-
sha: Specific commit SHA (default: latest)
|
|
27
|
-
ttl: Time to live (24h, 48h, 1w, close)
|
|
28
|
-
size: Environment size (standard, large)
|
|
29
|
-
|
|
30
|
-
Returns:
|
|
31
|
-
StartResult with success status and details
|
|
32
|
-
"""
|
|
33
|
-
# TODO: Implement environment creation
|
|
34
|
-
# 1. Get latest SHA if not provided
|
|
35
|
-
# 2. Create circus labels
|
|
36
|
-
# 3. Build Docker image
|
|
37
|
-
# 4. Deploy to ECS
|
|
38
|
-
# 5. Update labels with running state
|
|
39
|
-
|
|
40
|
-
return StartResult(success=False, error="Not yet implemented")
|
showtime/core/circus.py
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
🎪 Circus tent emoji label parsing and state management
|
|
3
|
-
|
|
4
|
-
Core logic for parsing GitHub labels with circus tent emoji patterns.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from dataclasses import dataclass
|
|
8
|
-
from datetime import datetime
|
|
9
|
-
from typing import TYPE_CHECKING, List, Optional
|
|
10
|
-
|
|
11
|
-
if TYPE_CHECKING:
|
|
12
|
-
from .github import GitHubInterface
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass
|
|
16
|
-
class Show:
|
|
17
|
-
"""Single ephemeral environment state from circus labels"""
|
|
18
|
-
|
|
19
|
-
pr_number: int
|
|
20
|
-
sha: str # 7-char commit SHA
|
|
21
|
-
status: str # building, built, deploying, running, updating, failed
|
|
22
|
-
ip: Optional[str] = None # Environment IP address
|
|
23
|
-
created_at: Optional[str] = None # ISO timestamp
|
|
24
|
-
ttl: str = "24h" # 24h, 48h, close, etc.
|
|
25
|
-
requested_by: Optional[str] = None # GitHub username
|
|
26
|
-
|
|
27
|
-
@property
|
|
28
|
-
def aws_service_name(self) -> str:
|
|
29
|
-
"""Deterministic ECS service name: pr-{pr_number}-{sha}"""
|
|
30
|
-
return f"pr-{self.pr_number}-{self.sha}"
|
|
31
|
-
|
|
32
|
-
@property
|
|
33
|
-
def aws_image_tag(self) -> str:
|
|
34
|
-
"""Deterministic ECR image tag: pr-{pr_number}-{sha}-ci"""
|
|
35
|
-
return f"pr-{self.pr_number}-{self.sha}-ci"
|
|
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
|
-
|
|
42
|
-
@property
|
|
43
|
-
def short_sha(self) -> str:
|
|
44
|
-
"""7-character SHA for display (GitHub standard)"""
|
|
45
|
-
return self.sha[:7]
|
|
46
|
-
|
|
47
|
-
@property
|
|
48
|
-
def is_active(self) -> bool:
|
|
49
|
-
"""Check if this is the currently active show"""
|
|
50
|
-
return self.status == "running"
|
|
51
|
-
|
|
52
|
-
@property
|
|
53
|
-
def is_building(self) -> bool:
|
|
54
|
-
"""Check if environment is currently building"""
|
|
55
|
-
return self.status == "building"
|
|
56
|
-
|
|
57
|
-
@property
|
|
58
|
-
def is_built(self) -> bool:
|
|
59
|
-
"""Check if environment is built (Docker complete, ready for deploy)"""
|
|
60
|
-
return self.status == "built"
|
|
61
|
-
|
|
62
|
-
@property
|
|
63
|
-
def is_deploying(self) -> bool:
|
|
64
|
-
"""Check if environment is currently deploying to AWS"""
|
|
65
|
-
return self.status == "deploying"
|
|
66
|
-
|
|
67
|
-
@property
|
|
68
|
-
def is_updating(self) -> bool:
|
|
69
|
-
"""Check if environment is currently updating"""
|
|
70
|
-
return self.status == "updating"
|
|
71
|
-
|
|
72
|
-
def needs_update(self, latest_sha: str) -> bool:
|
|
73
|
-
"""Check if environment needs update to latest SHA"""
|
|
74
|
-
return self.sha != latest_sha[:7]
|
|
75
|
-
|
|
76
|
-
def to_circus_labels(self) -> List[str]:
|
|
77
|
-
"""Convert show state to circus tent emoji labels (per-SHA format)"""
|
|
78
|
-
if not self.created_at:
|
|
79
|
-
self.created_at = datetime.utcnow().strftime("%Y-%m-%dT%H-%M")
|
|
80
|
-
|
|
81
|
-
labels = [
|
|
82
|
-
f"🎪 {self.sha} 🚦 {self.status}", # SHA-first status
|
|
83
|
-
f"🎪 🎯 {self.sha}", # Active pointer (no value)
|
|
84
|
-
f"🎪 {self.sha} 📅 {self.created_at}", # SHA-first timestamp
|
|
85
|
-
f"🎪 {self.sha} ⌛ {self.ttl}", # SHA-first TTL
|
|
86
|
-
]
|
|
87
|
-
|
|
88
|
-
if self.ip:
|
|
89
|
-
labels.append(f"🎪 {self.sha} 🌐 {self.ip}:8080")
|
|
90
|
-
|
|
91
|
-
if self.requested_by:
|
|
92
|
-
labels.append(f"🎪 {self.sha} 🤡 {self.requested_by}")
|
|
93
|
-
|
|
94
|
-
return labels
|
|
95
|
-
|
|
96
|
-
@classmethod
|
|
97
|
-
def from_circus_labels(cls, pr_number: int, labels: List[str], sha: str) -> Optional["Show"]:
|
|
98
|
-
"""Create Show from circus tent labels for specific SHA"""
|
|
99
|
-
show_data = {
|
|
100
|
-
"pr_number": pr_number,
|
|
101
|
-
"sha": sha,
|
|
102
|
-
"status": "building", # default
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
for label in labels:
|
|
106
|
-
if not label.startswith("🎪 "):
|
|
107
|
-
continue
|
|
108
|
-
|
|
109
|
-
parts = label.split(" ", 3) # Split into 4 parts for per-SHA format
|
|
110
|
-
|
|
111
|
-
if len(parts) == 3: # Old format: 🎪 🎯 sha
|
|
112
|
-
emoji, value = parts[1], parts[2]
|
|
113
|
-
if emoji == "🎯" and value == sha: # Active pointer
|
|
114
|
-
pass # This SHA is active
|
|
115
|
-
elif len(parts) == 4: # SHA-first format: 🎪 sha 🚦 status
|
|
116
|
-
label_sha, emoji, value = parts[1], parts[2], parts[3]
|
|
117
|
-
|
|
118
|
-
if label_sha != sha: # Only process labels for this SHA
|
|
119
|
-
continue
|
|
120
|
-
|
|
121
|
-
if emoji == "🚦": # Status
|
|
122
|
-
show_data["status"] = value
|
|
123
|
-
elif emoji == "📅": # Timestamp
|
|
124
|
-
show_data["created_at"] = value
|
|
125
|
-
elif emoji == "🌐": # IP with port
|
|
126
|
-
show_data["ip"] = value.replace(":8080", "") # Remove port for storage
|
|
127
|
-
elif emoji == "⌛": # TTL
|
|
128
|
-
show_data["ttl"] = value
|
|
129
|
-
elif emoji == "🤡": # User (clown!)
|
|
130
|
-
show_data["requested_by"] = value
|
|
131
|
-
|
|
132
|
-
# Only return Show if we found relevant labels for this SHA
|
|
133
|
-
if any(label.endswith(f" {sha}") for label in labels if "🎯" in label or "🏗️" in label):
|
|
134
|
-
return cls(**show_data)
|
|
135
|
-
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
class PullRequest:
|
|
140
|
-
"""GitHub PR with its shows parsed from circus labels"""
|
|
141
|
-
|
|
142
|
-
def __init__(self, pr_number: int, labels: List[str]):
|
|
143
|
-
self.pr_number = pr_number
|
|
144
|
-
self.labels = labels
|
|
145
|
-
self._shows = self._parse_shows_from_labels()
|
|
146
|
-
|
|
147
|
-
@property
|
|
148
|
-
def shows(self) -> List[Show]:
|
|
149
|
-
"""All shows found in labels"""
|
|
150
|
-
return self._shows
|
|
151
|
-
|
|
152
|
-
@property
|
|
153
|
-
def current_show(self) -> Optional[Show]:
|
|
154
|
-
"""The currently active show (from 🎯 label)"""
|
|
155
|
-
# Find the SHA that's marked as active (🎯)
|
|
156
|
-
active_sha = None
|
|
157
|
-
for label in self.labels:
|
|
158
|
-
if label.startswith("🎪 🎯 "):
|
|
159
|
-
active_sha = label.split(" ")[2]
|
|
160
|
-
break
|
|
161
|
-
|
|
162
|
-
if not active_sha:
|
|
163
|
-
return None
|
|
164
|
-
|
|
165
|
-
# Find the show with that SHA
|
|
166
|
-
for show in self.shows:
|
|
167
|
-
if show.sha == active_sha:
|
|
168
|
-
return show
|
|
169
|
-
|
|
170
|
-
return None
|
|
171
|
-
|
|
172
|
-
@property
|
|
173
|
-
def building_show(self) -> Optional[Show]:
|
|
174
|
-
"""Show currently being built (from 🏗️ label)"""
|
|
175
|
-
building_sha = None
|
|
176
|
-
for label in self.labels:
|
|
177
|
-
if label.startswith("🎪 🏗️ "):
|
|
178
|
-
building_sha = label.split(" ")[2]
|
|
179
|
-
break
|
|
180
|
-
|
|
181
|
-
if not building_sha:
|
|
182
|
-
return None
|
|
183
|
-
|
|
184
|
-
for show in self.shows:
|
|
185
|
-
if show.sha == building_sha:
|
|
186
|
-
return show
|
|
187
|
-
|
|
188
|
-
return None
|
|
189
|
-
|
|
190
|
-
@property
|
|
191
|
-
def circus_labels(self) -> List[str]:
|
|
192
|
-
"""All circus tent labels"""
|
|
193
|
-
return [label for label in self.labels if label.startswith("🎪 ")]
|
|
194
|
-
|
|
195
|
-
def has_shows(self) -> bool:
|
|
196
|
-
"""Check if PR has any shows"""
|
|
197
|
-
return len(self.shows) > 0
|
|
198
|
-
|
|
199
|
-
def get_show_by_sha(self, sha: str) -> Optional[Show]:
|
|
200
|
-
"""Get specific show by SHA"""
|
|
201
|
-
for show in self.shows:
|
|
202
|
-
if show.sha == sha[:7]:
|
|
203
|
-
return show
|
|
204
|
-
return None
|
|
205
|
-
|
|
206
|
-
def _parse_shows_from_labels(self) -> List[Show]:
|
|
207
|
-
"""Parse all shows from circus labels"""
|
|
208
|
-
shows = []
|
|
209
|
-
|
|
210
|
-
# Find all unique SHAs mentioned in labels
|
|
211
|
-
shas = set()
|
|
212
|
-
for label in self.labels:
|
|
213
|
-
if label.startswith("🎪 🎯 ") or label.startswith("🎪 🏗️ "):
|
|
214
|
-
sha = label.split(" ")[2]
|
|
215
|
-
shas.add(sha)
|
|
216
|
-
|
|
217
|
-
# Create Show object for each SHA
|
|
218
|
-
for sha in shas:
|
|
219
|
-
show = Show.from_circus_labels(self.pr_number, self.labels, sha)
|
|
220
|
-
if show:
|
|
221
|
-
shows.append(show)
|
|
222
|
-
|
|
223
|
-
return shows
|
|
224
|
-
|
|
225
|
-
@classmethod
|
|
226
|
-
def from_id(cls, pr_number: int, github: "GitHubInterface") -> "PullRequest":
|
|
227
|
-
"""Load PR with current labels from GitHub"""
|
|
228
|
-
labels = github.get_labels(pr_number)
|
|
229
|
-
return cls(pr_number, labels)
|
|
230
|
-
|
|
231
|
-
def refresh_labels(self, github: "GitHubInterface") -> None:
|
|
232
|
-
"""Refresh labels from GitHub and reparse shows"""
|
|
233
|
-
self.labels = github.get_labels(self.pr_number)
|
|
234
|
-
self._shows = self._parse_shows_from_labels()
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
def parse_ttl_days(ttl_str: str) -> Optional[float]:
|
|
238
|
-
"""Parse TTL string to days"""
|
|
239
|
-
import re
|
|
240
|
-
|
|
241
|
-
if ttl_str == "never":
|
|
242
|
-
return None # Never expire
|
|
243
|
-
|
|
244
|
-
if ttl_str == "close":
|
|
245
|
-
return None # Special handling needed
|
|
246
|
-
|
|
247
|
-
# Parse number + unit: 24h, 7d, 2w, etc.
|
|
248
|
-
match = re.match(r"(\d+(?:\.\d+)?)(h|d|w)", ttl_str.lower())
|
|
249
|
-
if not match:
|
|
250
|
-
return 2.0 # Default 2 days if invalid
|
|
251
|
-
|
|
252
|
-
value = float(match.group(1))
|
|
253
|
-
unit = match.group(2)
|
|
254
|
-
|
|
255
|
-
if unit == "h":
|
|
256
|
-
return value / 24.0 # Hours to days
|
|
257
|
-
elif unit == "d":
|
|
258
|
-
return value # Already in days
|
|
259
|
-
elif unit == "w":
|
|
260
|
-
return value * 7.0 # Weeks to days
|
|
261
|
-
|
|
262
|
-
return 2.0 # Default
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def get_effective_ttl(pr) -> Optional[float]:
|
|
266
|
-
"""Get effective TTL in days for a PR (handles multiple labels, conflicts)"""
|
|
267
|
-
ttl_labels = []
|
|
268
|
-
|
|
269
|
-
# Find all TTL labels for all shows
|
|
270
|
-
for show in pr.shows:
|
|
271
|
-
if show.ttl:
|
|
272
|
-
ttl_days = parse_ttl_days(show.ttl)
|
|
273
|
-
if ttl_days is None: # "never" or "close"
|
|
274
|
-
if show.ttl == "never":
|
|
275
|
-
return None # Never expire takes precedence
|
|
276
|
-
# For "close", continue checking others
|
|
277
|
-
else:
|
|
278
|
-
ttl_labels.append(ttl_days)
|
|
279
|
-
|
|
280
|
-
if not ttl_labels:
|
|
281
|
-
return 2.0 # Default 2 days
|
|
282
|
-
|
|
283
|
-
# Use longest duration if multiple labels
|
|
284
|
-
return max(ttl_labels)
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def short_sha(full_sha: str) -> str:
|
|
288
|
-
"""Truncate SHA to 7 characters for display (GitHub standard)"""
|
|
289
|
-
return full_sha[:7]
|