xce009 commited on
Commit
2b6ef22
·
1 Parent(s): 08240ea

deploy: auto-deploy 13:59:40

Browse files
.gitignore CHANGED
@@ -116,6 +116,7 @@ Thumbs.db
116
 
117
  logs/
118
  *.log
 
119
 
120
  *.db
121
  *.sqlite3
 
116
 
117
  logs/
118
  *.log
119
+ persistence/
120
 
121
  *.db
122
  *.sqlite3
app/api/v1/router.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
 
3
  from fastapi import APIRouter
4
 
5
- from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, vector_stores, web_search
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
@@ -23,3 +23,4 @@ api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
23
  api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
 
 
2
 
3
  from fastapi import APIRouter
4
 
5
+ from app.api.v1 import auth, batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, token_generator, vector_stores, web_search, webhook_socket
6
  from app.api.verify import router as verify_router
7
 
8
  api_v1_router = APIRouter()
 
23
  api_v1_router.include_router(token_generator.router, tags=["Token Generator"])
24
  api_v1_router.include_router(chat.router, tags=["Chat"])
25
  api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
26
+ api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
app/api/v1/system.py CHANGED
@@ -1,10 +1,14 @@
1
  from __future__ import annotations
2
 
 
3
  import platform
 
4
  import time
5
  from datetime import datetime, timezone
 
6
 
7
- from fastapi import APIRouter, Depends
 
8
 
9
  from app.api.deps import get_extraction_service, require_auth
10
  from app.config import get_settings
@@ -18,6 +22,7 @@ from app.core.constants import (
18
  TEXT_EXTENSIONS,
19
  WEB_EXTENSIONS,
20
  )
 
21
  from app.models.schemas import (
22
  HealthResponse,
23
  InfoResponse,
@@ -29,6 +34,7 @@ from app.services.extraction_service import ExtractionService
29
  router = APIRouter()
30
  _settings = get_settings()
31
  _START_TIME = time.time()
 
32
 
33
 
34
  @router.get("/health", response_model=HealthResponse, summary="Health check")
@@ -108,3 +114,74 @@ async def list_spacy_labels(
108
  "phone": {"source_type": "regex", "pattern": r"\b\d{3}-\d{3}-\d{4}\b"},
109
  },
110
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import io
4
  import platform
5
+ import tarfile
6
  import time
7
  from datetime import datetime, timezone
8
+ from pathlib import Path
9
 
10
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
11
+ from fastapi.responses import StreamingResponse
12
 
13
  from app.api.deps import get_extraction_service, require_auth
14
  from app.config import get_settings
 
22
  TEXT_EXTENSIONS,
23
  WEB_EXTENSIONS,
24
  )
25
+ from app.core.logger import get_logger
26
  from app.models.schemas import (
27
  HealthResponse,
28
  InfoResponse,
 
34
  router = APIRouter()
35
  _settings = get_settings()
36
  _START_TIME = time.time()
37
+ _logger = get_logger(__name__)
38
 
39
 
40
  @router.get("/health", response_model=HealthResponse, summary="Health check")
 
114
  "phone": {"source_type": "regex", "pattern": r"\b\d{3}-\d{3}-\d{4}\b"},
115
  },
116
  )
117
+
118
+
119
+ def _build_archive(data_dir: Path) -> io.BytesIO:
120
+ buf = io.BytesIO()
121
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
122
+ for path in sorted(data_dir.rglob("*")):
123
+ if path.is_file():
124
+ arcname = path.relative_to(data_dir.parent)
125
+ tar.add(str(path), arcname=str(arcname))
126
+ buf.seek(0)
127
+ return buf
128
+
129
+
130
+ def _get_data_dir() -> Path:
131
+ raw = _settings.data_dir
132
+ p = Path(raw)
133
+ if not p.is_absolute():
134
+ p = Path.cwd() / p
135
+ return p.resolve()
136
+
137
+
138
+ @router.get("/backup", summary="Download a full data backup archive")
139
+ async def download_backup(token: str = Depends(require_auth)):
140
+ data_dir = _get_data_dir()
141
+ if not data_dir.is_dir():
142
+ raise HTTPException(status_code=404, detail="Data directory not found")
143
+ try:
144
+ archive = _build_archive(data_dir)
145
+ except Exception as exc:
146
+ _logger.error("Backup creation failed: %s", exc)
147
+ raise HTTPException(status_code=500, detail=f"Backup failed: {exc}")
148
+
149
+ return StreamingResponse(
150
+ archive,
151
+ media_type="application/gzip",
152
+ headers={"Content-Disposition": "attachment; filename=backup.tar.gz"},
153
+ )
154
+
155
+
156
+ @router.post("/backup/restore", summary="Upload and restore a data backup archive")
157
+ async def upload_and_restore(
158
+ file: UploadFile = File(...),
159
+ token: str = Depends(require_auth),
160
+ ):
161
+ data_dir = _get_data_dir()
162
+ data_dir.mkdir(parents=True, exist_ok=True)
163
+
164
+ try:
165
+ content = await file.read()
166
+ except Exception as exc:
167
+ raise HTTPException(status_code=400, detail=f"Failed to read upload: {exc}")
168
+
169
+ if not content:
170
+ raise HTTPException(status_code=400, detail="Empty file")
171
+
172
+ restored = 0
173
+ try:
174
+ with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as tar:
175
+ for member in tar.getmembers():
176
+ if member.isfile():
177
+ tar.extract(member, path=data_dir.parent)
178
+ restored += 1
179
+ except tarfile.TarError as exc:
180
+ raise HTTPException(status_code=400, detail=f"Invalid archive: {exc}")
181
+
182
+ return {
183
+ "success": True,
184
+ "message": f"Restored {restored} files to {data_dir}",
185
+ "files_restored": restored,
186
+ "data_dir": str(data_dir),
187
+ }
app/api/v1/webhook_socket.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
7
+
8
+ from app.api.deps import require_auth
9
+ from app.models.schemas import (
10
+ ChannelCreateRequest,
11
+ ChannelCreateResponse,
12
+ ChannelDeleteResponse,
13
+ ChannelInfoResponse,
14
+ ChannelListItem,
15
+ ChannelListResponse,
16
+ WebhookResponse,
17
+ WebhookSocketStatsResponse,
18
+ )
19
+ from app.services.webhook_socket_service import get_manager, verify_signature
20
+
21
+ router = APIRouter()
22
+ manager = get_manager()
23
+
24
+
25
+ @router.post("/channels", response_model=ChannelCreateResponse, summary="Create a webhook channel")
26
+ async def create_channel(
27
+ body: ChannelCreateRequest,
28
+ request: Request,
29
+ token: str = Depends(require_auth),
30
+ ):
31
+ if body.channel_id and manager.get_channel(body.channel_id):
32
+ from fastapi import HTTPException
33
+ raise HTTPException(status_code=409, detail=f"Channel '{body.channel_id}' already exists")
34
+
35
+ ch = manager.create_channel(
36
+ channel_id=body.channel_id,
37
+ secret=body.secret,
38
+ buffer_size=body.buffer_size,
39
+ )
40
+
41
+ host = request.headers.get("host", "localhost:7860")
42
+ scheme = request.headers.get("x-forwarded-proto", "http")
43
+ ws_scheme = "wss" if scheme == "https" else "ws"
44
+
45
+ return ChannelCreateResponse(
46
+ channel_id=ch.channel_id,
47
+ webhook_url=f"{scheme}://{host}/api/v1/webhook/{ch.channel_id}",
48
+ ws_url=f"{ws_scheme}://{host}/api/v1/ws/{ch.channel_id}",
49
+ secret=ch.secret,
50
+ buffer_size=ch.buffer_size,
51
+ )
52
+
53
+
54
+ @router.get("/channels", response_model=ChannelListResponse, summary="List all webhook channels")
55
+ async def list_channels(
56
+ token: str = Depends(require_auth),
57
+ ):
58
+ channels = []
59
+ for cid, ch in manager.channels.items():
60
+ channels.append(ChannelListItem(
61
+ channel_id=cid,
62
+ subscribers=len(ch.subscribers),
63
+ messages=ch.message_count,
64
+ buffered=len(ch.history),
65
+ created_at=ch.created_at,
66
+ last_activity=ch.last_activity,
67
+ ))
68
+ return ChannelListResponse(channels=channels)
69
+
70
+
71
+ @router.get("/channels/{channel_id}", response_model=ChannelInfoResponse, summary="Get channel info")
72
+ async def channel_info(
73
+ channel_id: str,
74
+ token: str = Depends(require_auth),
75
+ ):
76
+ from fastapi import HTTPException
77
+ ch = manager.get_channel(channel_id)
78
+ if not ch:
79
+ raise HTTPException(status_code=404, detail="Channel not found")
80
+ return ChannelInfoResponse(
81
+ channel_id=ch.channel_id,
82
+ subscribers=len(ch.subscribers),
83
+ messages=ch.message_count,
84
+ buffered=len(ch.history),
85
+ buffer_size=ch.buffer_size,
86
+ has_secret=ch.secret is not None,
87
+ created_at=ch.created_at,
88
+ last_activity=ch.last_activity,
89
+ )
90
+
91
+
92
+ @router.delete("/channels/{channel_id}", response_model=ChannelDeleteResponse, summary="Delete a channel")
93
+ async def delete_channel(
94
+ channel_id: str,
95
+ token: str = Depends(require_auth),
96
+ ):
97
+ from fastapi import HTTPException
98
+ if manager.delete_channel(channel_id):
99
+ return ChannelDeleteResponse(deleted=channel_id)
100
+ raise HTTPException(status_code=404, detail="Channel not found")
101
+
102
+
103
+ @router.post("/webhook/{channel_id}", response_model=WebhookResponse, summary="Send webhook payload to channel")
104
+ async def handle_webhook(
105
+ channel_id: str,
106
+ request: Request,
107
+ ):
108
+ from fastapi import HTTPException
109
+ ch = manager.get_channel(channel_id)
110
+ if not ch:
111
+ raise HTTPException(status_code=404, detail="Channel not found")
112
+
113
+ raw_body = await request.body()
114
+
115
+ if ch.secret:
116
+ sig_header = request.headers.get("X-Signature-256") or request.headers.get("X-Hub-Signature-256", "")
117
+ if not sig_header:
118
+ raise HTTPException(status_code=401, detail="Missing signature header")
119
+ if not verify_signature(ch.secret, raw_body, sig_header):
120
+ raise HTTPException(status_code=401, detail="Invalid signature")
121
+
122
+ content_type = request.headers.get("content-type", "")
123
+
124
+ if "json" in content_type:
125
+ try:
126
+ payload = json.loads(raw_body)
127
+ except json.JSONDecodeError:
128
+ payload = raw_body.decode(errors="replace")
129
+ elif "x-www-form-urlencoded" in content_type:
130
+ form = await request.form()
131
+ payload = dict(form)
132
+ else:
133
+ try:
134
+ payload = json.loads(raw_body)
135
+ except json.JSONDecodeError:
136
+ payload = raw_body.decode(errors="replace")
137
+
138
+ fwd_headers: dict[str, str] = {}
139
+ for h in ("X-GitHub-Event", "X-GitHub-Delivery", "X-Event-Type",
140
+ "X-Webhook-Name", "Content-Type", "User-Agent"):
141
+ if h in request.headers:
142
+ fwd_headers[h] = request.headers[h]
143
+
144
+ sent = await manager.publish(channel_id, payload, fwd_headers)
145
+
146
+ ch_ref = manager.get_channel(channel_id)
147
+ return WebhookResponse(
148
+ status="delivered",
149
+ channel=channel_id,
150
+ subscribers_notified=max(sent, 0),
151
+ message_id=ch_ref.message_count if ch_ref else 0,
152
+ )
153
+
154
+
155
+ @router.post("/hook/{channel_id}", response_model=WebhookResponse, summary="Send webhook payload (short alias)")
156
+ async def handle_webhook_short(
157
+ channel_id: str,
158
+ request: Request,
159
+ ):
160
+ return await handle_webhook(channel_id, request)
161
+
162
+
163
+ @router.get("/ws/{channel_id}")
164
+ async def websocket_endpoint(
165
+ channel_id: str,
166
+ websocket: WebSocket,
167
+ secret: str = "",
168
+ ):
169
+ ch = manager.get_channel(channel_id)
170
+ if not ch:
171
+ await websocket.accept()
172
+ await websocket.send_json({"event": "error", "message": "channel not found"})
173
+ await websocket.close(code=4404)
174
+ return
175
+
176
+ if ch.secret:
177
+ import hmac as hmac_mod
178
+ if not hmac_mod.compare_digest(secret, ch.secret):
179
+ await websocket.accept()
180
+ await websocket.send_json({"event": "error", "message": "unauthorized"})
181
+ await websocket.close(code=4401)
182
+ return
183
+
184
+ await websocket.accept()
185
+
186
+ q = manager.subscribe(channel_id, websocket)
187
+ if q is None:
188
+ await websocket.send_json({"event": "error", "message": "subscribe failed"})
189
+ await websocket.close()
190
+ return
191
+
192
+ await websocket.send_json({
193
+ "event": "connected",
194
+ "channel": channel_id,
195
+ "message": f"Listening on channel '{channel_id}'",
196
+ "buffered": len(ch.history),
197
+ })
198
+
199
+ async def forward_to_ws():
200
+ try:
201
+ while True:
202
+ try:
203
+ msg = await asyncio.wait_for(q.get(), timeout=30)
204
+ except asyncio.TimeoutError:
205
+ try:
206
+ await websocket.send_json({"event": "ping"})
207
+ except Exception:
208
+ break
209
+ continue
210
+ try:
211
+ await websocket.send_json(msg)
212
+ except Exception:
213
+ break
214
+ except asyncio.CancelledError:
215
+ pass
216
+
217
+ async def read_from_ws():
218
+ try:
219
+ while True:
220
+ try:
221
+ data = await websocket.receive_text()
222
+ try:
223
+ msg_data = json.loads(data)
224
+ if msg_data.get("type") == "ping":
225
+ await websocket.send_json({"event": "pong"})
226
+ except json.JSONDecodeError:
227
+ pass
228
+ except WebSocketDisconnect:
229
+ break
230
+ except asyncio.CancelledError:
231
+ pass
232
+
233
+ fwd_task = asyncio.create_task(forward_to_ws())
234
+ read_task = asyncio.create_task(read_from_ws())
235
+
236
+ try:
237
+ done, pending = await asyncio.wait(
238
+ [fwd_task, read_task], return_when=asyncio.FIRST_COMPLETED
239
+ )
240
+ finally:
241
+ fwd_task.cancel()
242
+ read_task.cancel()
243
+ manager.unsubscribe(channel_id, websocket)
244
+
245
+
246
+ @router.get("/webhook-socket/stats", response_model=WebhookSocketStatsResponse, summary="Webhook/socket server stats")
247
+ async def ws_stats(
248
+ token: str = Depends(require_auth),
249
+ ):
250
+ return manager.stats()
app/models/schemas.py CHANGED
@@ -659,3 +659,63 @@ class DeleteResponse(BaseModel):
659
  deleted_count: int
660
  time_ms: float
661
  error: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  deleted_count: int
660
  time_ms: float
661
  error: Optional[str] = None
662
+
663
+
664
+ # ---------------------------------------------------------------------------
665
+ # Webhook / Socket
666
+ # ---------------------------------------------------------------------------
667
+
668
+ class ChannelCreateRequest(BaseModel):
669
+ channel_id: Optional[str] = Field(None, min_length=1, max_length=64, description="Optional custom channel ID")
670
+ secret: Optional[str] = Field(None, min_length=1, description="HMAC secret for webhook verification")
671
+ buffer_size: Optional[int] = Field(None, ge=0, le=10000, description="Replay buffer size (0 = no replay)")
672
+
673
+
674
+ class ChannelCreateResponse(BaseModel):
675
+ channel_id: str
676
+ webhook_url: str
677
+ ws_url: str
678
+ secret: Optional[str] = None
679
+ buffer_size: int
680
+
681
+
682
+ class ChannelInfoResponse(BaseModel):
683
+ channel_id: str
684
+ subscribers: int
685
+ messages: int
686
+ buffered: int
687
+ buffer_size: int
688
+ has_secret: bool
689
+ created_at: float
690
+ last_activity: float
691
+
692
+
693
+ class ChannelListItem(BaseModel):
694
+ channel_id: str
695
+ subscribers: int
696
+ messages: int
697
+ buffered: int
698
+ created_at: float
699
+ last_activity: float
700
+
701
+
702
+ class ChannelListResponse(BaseModel):
703
+ channels: List[ChannelListItem]
704
+
705
+
706
+ class ChannelDeleteResponse(BaseModel):
707
+ deleted: str
708
+
709
+
710
+ class WebhookResponse(BaseModel):
711
+ status: str
712
+ channel: str
713
+ subscribers_notified: int
714
+ message_id: int
715
+
716
+
717
+ class WebhookSocketStatsResponse(BaseModel):
718
+ channels: int
719
+ total_messages: int
720
+ total_subscribers: int
721
+ channels_detail: Dict[str, Dict[str, Any]]
app/services/webhook_socket_service.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from app.core.logger import get_logger
12
+
13
+ log = get_logger("webhook-socket")
14
+
15
+
16
+ @dataclass
17
+ class Channel:
18
+ channel_id: str
19
+ secret: Optional[str] = None
20
+ buffer_size: int = 0
21
+ created_at: float = field(default_factory=time.time)
22
+ message_count: int = 0
23
+ last_activity: float = field(default_factory=time.time)
24
+ subscribers: Dict[Any, asyncio.Queue] = field(default_factory=dict)
25
+ history: List[dict] = field(default_factory=list)
26
+
27
+
28
+ class ChannelManager:
29
+ def __init__(self, default_buffer: int = 0):
30
+ self.channels: Dict[str, Channel] = {}
31
+ self.default_buffer = default_buffer
32
+ self.total_messages = 0
33
+
34
+ def create_channel(
35
+ self,
36
+ channel_id: Optional[str] = None,
37
+ secret: Optional[str] = None,
38
+ buffer_size: Optional[int] = None,
39
+ ) -> Channel:
40
+ ch_id = channel_id or uuid.uuid4().hex[:16]
41
+ buf = buffer_size if buffer_size is not None else self.default_buffer
42
+ ch = Channel(channel_id=ch_id, secret=secret, buffer_size=buf)
43
+ self.channels[ch_id] = ch
44
+ log.info("Channel created id=%s buffer=%d secret=%s", ch_id, buf, "yes" if secret else "no")
45
+ return ch
46
+
47
+ def get_channel(self, channel_id: str) -> Optional[Channel]:
48
+ return self.channels.get(channel_id)
49
+
50
+ def delete_channel(self, channel_id: str) -> bool:
51
+ if channel_id in self.channels:
52
+ for q in self.channels[channel_id].subscribers.values():
53
+ q.put_nowait({"event": "channel_deleted", "channel": channel_id})
54
+ del self.channels[channel_id]
55
+ log.info("Channel deleted id=%s", channel_id)
56
+ return True
57
+ return False
58
+
59
+ def subscribe(self, channel_id: str, ws: Any) -> Optional[asyncio.Queue]:
60
+ ch = self.channels.get(channel_id)
61
+ if not ch:
62
+ return None
63
+ q: asyncio.Queue = asyncio.Queue()
64
+ ch.subscribers[ws] = q
65
+ log.info(
66
+ "Subscriber joined channel=%s total_subs=%d",
67
+ channel_id, len(ch.subscribers),
68
+ )
69
+ for msg in ch.history:
70
+ q.put_nowait(msg)
71
+ return q
72
+
73
+ def unsubscribe(self, channel_id: str, ws: Any) -> None:
74
+ ch = self.channels.get(channel_id)
75
+ if ch and ws in ch.subscribers:
76
+ del ch.subscribers[ws]
77
+ log.info(
78
+ "Subscriber left channel=%s total_subs=%d",
79
+ channel_id, len(ch.subscribers),
80
+ )
81
+
82
+ async def publish(
83
+ self,
84
+ channel_id: str,
85
+ payload: Any,
86
+ headers: Optional[Dict[str, str]] = None,
87
+ ) -> int:
88
+ ch = self.channels.get(channel_id)
89
+ if not ch:
90
+ return -1
91
+
92
+ message = {
93
+ "event": "message",
94
+ "channel": channel_id,
95
+ "timestamp": time.time(),
96
+ "id": uuid.uuid4().hex[:12],
97
+ "payload": payload,
98
+ "headers": headers or {},
99
+ }
100
+
101
+ ch.message_count += 1
102
+ ch.last_activity = time.time()
103
+ self.total_messages += 1
104
+
105
+ if ch.buffer_size > 0:
106
+ ch.history.append(message)
107
+ while len(ch.history) > ch.buffer_size:
108
+ ch.history.pop(0)
109
+
110
+ dead: List[Any] = []
111
+ sent = 0
112
+ for ws, q in list(ch.subscribers.items()):
113
+ if getattr(ws, "closed", False):
114
+ dead.append(ws)
115
+ continue
116
+ await q.put(message)
117
+ sent += 1
118
+
119
+ for ws in dead:
120
+ del ch.subscribers[ws]
121
+
122
+ log.info(
123
+ "Published channel=%s subs=%d msg_total=%d",
124
+ channel_id, sent, ch.message_count,
125
+ )
126
+ return sent
127
+
128
+ def stats(self) -> dict:
129
+ return {
130
+ "channels": len(self.channels),
131
+ "total_messages": self.total_messages,
132
+ "total_subscribers": sum(len(c.subscribers) for c in self.channels.values()),
133
+ "channels_detail": {
134
+ cid: {
135
+ "subscribers": len(ch.subscribers),
136
+ "messages": ch.message_count,
137
+ "buffered": len(ch.history),
138
+ "last_activity": ch.last_activity,
139
+ "has_secret": ch.secret is not None,
140
+ }
141
+ for cid, ch in self.channels.items()
142
+ },
143
+ }
144
+
145
+
146
+ def sign_payload(secret: str, raw_body: bytes) -> str:
147
+ return "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
148
+
149
+
150
+ def verify_signature(secret: str, raw_body: bytes, signature: str) -> bool:
151
+ expected = sign_payload(secret, raw_body)
152
+ return hmac.compare_digest(expected, signature)
153
+
154
+
155
+ _manager: Optional[ChannelManager] = None
156
+
157
+
158
+ def get_manager() -> ChannelManager:
159
+ global _manager
160
+ if _manager is None:
161
+ _manager = ChannelManager()
162
+ return _manager
163
+
164
+
165
+ def init_manager(default_buffer: int = 0) -> ChannelManager:
166
+ global _manager
167
+ _manager = ChannelManager(default_buffer=default_buffer)
168
+ return _manager
deploy.py ADDED
@@ -0,0 +1,959 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ deploy.py — Persistent data deployment for Hugging Face Spaces.
4
+
5
+ Usage:
6
+ python deploy.py
7
+
8
+ Flow:
9
+ 1. Back up existing data from the deployed Space -> persistence/
10
+ 2. Commit and push local changes to the Space repository
11
+ 3. Wait for the Space to redeploy (poll /health)
12
+ 4. Restore the backed-up data to the new deployment
13
+ 5. Generate a comprehensive audit report
14
+ """
15
+
16
+ import argparse
17
+ import io
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ import tarfile
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from datetime import datetime, timezone
28
+ from pathlib import Path
29
+ from typing import Any, Optional
30
+
31
+ # ─────────────────────────────────────────────────────────────
32
+ # Console encoding detection
33
+ # ─────────────────────────────────────────────────────────────
34
+
35
+ _USE_UNICODE = True
36
+ try:
37
+ "\u2713".encode(sys.stdout.encoding or "utf-8")
38
+ except (UnicodeEncodeError, UnicodeDecodeError, LookupError):
39
+ _USE_UNICODE = False
40
+
41
+ # ─────────────────────────────────────────────────────────────
42
+ # ANSI colours & helpers
43
+ # ─────────────────────────────────────────────────────────────
44
+
45
+ GREEN = "\033[92m"
46
+ RED = "\033[91m"
47
+ YELLOW = "\033[93m"
48
+ CYAN = "\033[96m"
49
+ BOLD = "\033[1m"
50
+ RESET = "\033[0m"
51
+
52
+
53
+ def _c(colour: str, text: str) -> str:
54
+ return f"{colour}{text}{RESET}"
55
+
56
+
57
+ def _ok(text: str) -> str:
58
+ return _c(GREEN, text)
59
+
60
+
61
+ def _fail(text: str) -> str:
62
+ return _c(RED, text)
63
+
64
+
65
+ def _warn(text: str) -> str:
66
+ return _c(YELLOW, text)
67
+
68
+
69
+ def _info(text: str) -> str:
70
+ return _c(CYAN, text)
71
+
72
+
73
+ def _bold(text: str) -> str:
74
+ return _c(BOLD, text)
75
+
76
+
77
+ # Icon constants (must be defined outside f-string expressions for Python < 3.12)
78
+ # Unicode vs ASCII fallbacks for Windows console compatibility
79
+ if _USE_UNICODE:
80
+ _OK_ICON = _ok("\u2713")
81
+ _FAIL_ICON = _fail("\u2717")
82
+ _WARN_ICON = _warn("\u26A0")
83
+ _INFO_ICON = _info("\u25B8")
84
+ _BULLET = "\u2022"
85
+ _DASH = "\u2014"
86
+ _TABLE_TL = "\u2554"
87
+ _TABLE_TR = "\u2557"
88
+ _TABLE_BL = "\u255A"
89
+ _TABLE_BR = "\u255D"
90
+ _TABLE_H = "\u2550"
91
+ _TABLE_V = "\u2551"
92
+ _TABLE_TM = "\u2560"
93
+ _TABLE_BM = "\u255A"
94
+ _TABLE_ML = "\u251C"
95
+ _TABLE_MR = "\u2524"
96
+ _TABLE_MM = "\u253C"
97
+ _TABLE_MH = "\u2500"
98
+ _TABLE_MV = "\u2502"
99
+ _TABLE_TML = "\u255F"
100
+ _TABLE_TMR = "\u257E"
101
+ else:
102
+ _OK_ICON = _ok("v")
103
+ _FAIL_ICON = _fail("x")
104
+ _WARN_ICON = _warn("!")
105
+ _INFO_ICON = _info(">")
106
+ _BULLET = "*"
107
+ _DASH = "-"
108
+ _TABLE_TL = "+"
109
+ _TABLE_TR = "+"
110
+ _TABLE_BL = "+"
111
+ _TABLE_BR = "+"
112
+ _TABLE_H = "="
113
+ _TABLE_V = "|"
114
+ _TABLE_TM = "+"
115
+ _TABLE_BM = "+"
116
+ _TABLE_ML = "+"
117
+ _TABLE_MR = "+"
118
+ _TABLE_MM = "+"
119
+ _TABLE_MH = "-"
120
+ _TABLE_MV = "|"
121
+ _TABLE_TML = "+"
122
+ _TABLE_TMR = "-"
123
+
124
+
125
+ def _indicator(status: str) -> str:
126
+ mapping = {
127
+ "passed": _OK_ICON,
128
+ "failed": _FAIL_ICON,
129
+ "skipped": _WARN_ICON,
130
+ "running": _INFO_ICON,
131
+ }
132
+ return mapping.get(status, _DASH)
133
+
134
+
135
+ def _timestamp() -> str:
136
+ return datetime.now(timezone.utc).strftime("%H:%M:%S")
137
+
138
+
139
+ def _human_size(n_bytes: int) -> str:
140
+ for unit in ("B", "KB", "MB", "GB"):
141
+ if n_bytes < 1024:
142
+ return f"{n_bytes:.2f} {unit}"
143
+ n_bytes /= 1024
144
+ return f"{n_bytes:.2f} TB"
145
+
146
+
147
+ def _human_duration(seconds: float) -> str:
148
+ if seconds < 60:
149
+ return f"{seconds:.1f}s"
150
+ mins = int(seconds // 60)
151
+ secs = int(seconds % 60)
152
+ return f"{mins}m {secs}s"
153
+
154
+
155
+ # ─────────────────────────────────────────────────────────────
156
+ # Environment loading
157
+ # ─────────────────────────────────────────────────────────────
158
+
159
+
160
+ def load_env(env_path: str = ".env") -> dict[str, str]:
161
+ env: dict[str, str] = {}
162
+ p = Path(env_path)
163
+ if not p.is_file():
164
+ return env
165
+ for line in p.read_text(encoding="utf-8").splitlines():
166
+ line = line.strip()
167
+ if not line or line.startswith("#"):
168
+ continue
169
+ m = re.match(r"^([A-Za-z_][A-Za-z_0-9]*)\s*=\s*(.*?)\s*$", line)
170
+ if not m:
171
+ continue
172
+ key = m.group(1)
173
+ val = m.group(2)
174
+ if val.startswith('"') and val.endswith('"'):
175
+ val = val[1:-1]
176
+ elif val.startswith("'") and val.endswith("'"):
177
+ val = val[1:-1]
178
+ env[key] = val
179
+ return env
180
+
181
+
182
+ # ─────────────────────────────────────────────────────────────
183
+ # HTTP helpers
184
+ # ─────────────────────────────────────────────────────────────
185
+
186
+ BACKUP_ARCHIVE = "backup.tar.gz"
187
+ RESTORE_ENDPOINT = "/api/v1/system/backup/restore"
188
+ BACKUP_ENDPOINT = "/api/v1/system/backup"
189
+ HEALTH_ENDPOINT = "/health"
190
+ HF_API_SPACES = "https://huggingface.co/api/spaces"
191
+
192
+
193
+ def _build_url(base: str, path: str) -> str:
194
+ base = base.rstrip("/")
195
+ path = path.lstrip("/")
196
+ return f"{base}/{path}"
197
+
198
+
199
+ def http_get(url: str, headers: dict[str, str], timeout: int = 120) -> tuple[int, bytes]:
200
+ req = urllib.request.Request(url, headers=headers, method="GET")
201
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
202
+ return resp.status, resp.read()
203
+
204
+
205
+ def http_get_stream(
206
+ url: str, headers: dict[str, str], dest: Path, timeout: int = 300
207
+ ) -> tuple[int, int]:
208
+ req = urllib.request.Request(url, headers=headers, method="GET")
209
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
210
+ total = 0
211
+ with open(dest, "wb") as f:
212
+ while True:
213
+ chunk = resp.read(65536)
214
+ if not chunk:
215
+ break
216
+ f.write(chunk)
217
+ total += len(chunk)
218
+ return resp.status, total
219
+
220
+
221
+ def http_post_multipart(
222
+ url: str,
223
+ file_path: Path,
224
+ field_name: str,
225
+ headers: dict[str, str],
226
+ timeout: int = 300,
227
+ ) -> tuple[int, bytes]:
228
+ boundary = "----DeployBoundary" + hex(int(time.time() * 1e6))[2:]
229
+ data = io.BytesIO()
230
+
231
+ data.write(f"--{boundary}\r\n".encode())
232
+ data.write(
233
+ f'Content-Disposition: form-data; name="{field_name}"; filename="{file_path.name}"\r\n'.encode()
234
+ )
235
+ data.write(b"Content-Type: application/gzip\r\n\r\n")
236
+ data.write(file_path.read_bytes())
237
+ data.write(f"\r\n--{boundary}--\r\n".encode())
238
+
239
+ body = data.getvalue()
240
+ content_type = f"multipart/form-data; boundary={boundary}"
241
+
242
+ req_headers = {**headers, "Content-Type": content_type}
243
+ req = urllib.request.Request(url, data=body, headers=req_headers, method="POST")
244
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
245
+ return resp.status, resp.read()
246
+
247
+
248
+ def http_get_json(
249
+ url: str, headers: dict[str, str], timeout: int = 30
250
+ ) -> Optional[dict[str, Any]]:
251
+ try:
252
+ req = urllib.request.Request(url, headers=headers, method="GET")
253
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
254
+ return json.loads(resp.read().decode("utf-8"))
255
+ except Exception:
256
+ return None
257
+
258
+
259
+ def http_head(url: str, timeout: int = 30) -> Optional[int]:
260
+ try:
261
+ req = urllib.request.Request(url, method="HEAD")
262
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
263
+ return resp.status
264
+ except urllib.error.URLError:
265
+ return None
266
+
267
+
268
+ # ─────────────────────────────────────────────────────────────
269
+ # Git helpers
270
+ # ─────────────────────────────────────────────────────────────
271
+
272
+
273
+ def _run_git(args: list[str], cwd: str | None = None) -> tuple[int, str]:
274
+ cmd = ["git"] + args
275
+ result = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd)
276
+ return result.returncode, result.stdout.strip()
277
+
278
+
279
+ def git_status(cwd: str) -> list[str]:
280
+ rc, out = _run_git(["status", "--porcelain"], cwd=cwd)
281
+ if rc != 0:
282
+ return []
283
+ lines = [line for line in out.split("\n") if line.strip()]
284
+ return lines
285
+
286
+
287
+ def git_add_all(cwd: str) -> bool:
288
+ changes = git_status(cwd)
289
+ if not changes:
290
+ return True
291
+ paths = []
292
+ for line in changes:
293
+ fn = line[3:].strip() if len(line) > 3 else ""
294
+ if fn and fn.lower() != "nul":
295
+ paths.append(fn)
296
+ if not paths:
297
+ return True
298
+ rc, _ = _run_git(["add", "--"] + paths, cwd=cwd)
299
+ return rc == 0
300
+
301
+
302
+ def git_commit(cwd: str, message: str) -> tuple[bool, str]:
303
+ rc, out = _run_git(["commit", "-m", message], cwd=cwd)
304
+ if rc == 0:
305
+ m = re.search(r"\[[^\]]+ ([a-f0-9]+)\]", out)
306
+ sha = m.group(1) if m else "unknown"
307
+ return True, sha
308
+ if "nothing to commit" in out.lower() or "no changes" in out.lower():
309
+ return True, "no-change"
310
+ return False, out
311
+
312
+
313
+ def git_push(cwd: str, remote: str = "origin", branch: str = "main") -> tuple[bool, str]:
314
+ rc, out = _run_git(["push", remote, branch], cwd=cwd)
315
+ return rc == 0, out
316
+
317
+
318
+ def _parse_hf_space_from_url(url: str) -> tuple[str, str]:
319
+ m = re.search(r"huggingface\.co/spaces/([^/]+)/([^/\s]+)", url)
320
+ if m:
321
+ return m.group(1), m.group(2).rstrip("/")
322
+ return "aetherbase", "llm-ready-data"
323
+
324
+
325
+ def git_remote_url(cwd: str) -> str:
326
+ rc, out = _run_git(["remote", "get-url", "origin"], cwd=cwd)
327
+ if rc != 0:
328
+ return ""
329
+ return out
330
+
331
+
332
+ def git_log(cwd: str, n: int = 3) -> str:
333
+ _, out = _run_git(["log", "--oneline", f"-{n}"], cwd=cwd)
334
+ return out
335
+
336
+
337
+ # ─────────────────────────────────────────────────────────────
338
+ # Core deployment logic
339
+ # ─────────────────────────────────────────────────────────────
340
+
341
+
342
+ class AuditReport:
343
+ def __init__(self):
344
+ self.start_time = time.time()
345
+ self.fields: dict[str, Any] = {
346
+ "backup_started": "",
347
+ "backup_completed": "",
348
+ "backup_files": 0,
349
+ "backup_size": 0,
350
+ "backup_status": "pending",
351
+ "commit_status": "pending",
352
+ "commit_hash": "",
353
+ "push_status": "pending",
354
+ "deploy_status": "pending",
355
+ "deploy_duration": "",
356
+ "restore_status": "pending",
357
+ "restore_files": 0,
358
+ "verification_status": "pending",
359
+ "errors": [],
360
+ "warnings": [],
361
+ }
362
+
363
+ def end(self):
364
+ self.fields["total_duration"] = _human_duration(time.time() - self.start_time)
365
+
366
+ def set(self, key: str, value: Any):
367
+ self.fields[key] = value
368
+
369
+ def error(self, msg: str):
370
+ self.fields["errors"].append(msg)
371
+
372
+ def warn(self, msg: str):
373
+ self.fields["warnings"].append(msg)
374
+
375
+ def _row(self, label: str, status: str, detail: str) -> str:
376
+ icon = _indicator(status)
377
+ return f"{_TABLE_MV} {label:<28} {icon:<2} {detail:<30} {_TABLE_MV}"
378
+
379
+ def _sep(self) -> str:
380
+ return f"{_TABLE_ML}{_TABLE_MH * 28}{_TABLE_MM}{_TABLE_MH * 4}{_TABLE_MM}{_TABLE_MH * 32}{_TABLE_MR}"
381
+
382
+ def print(self):
383
+ self.end()
384
+ f = self.fields
385
+ err_count = len(f["errors"])
386
+ warn_count = len(f["warnings"])
387
+
388
+ start_dt = datetime.fromtimestamp(self.start_time).strftime("%c")
389
+
390
+ lines: list[str] = []
391
+ lines.append("")
392
+ lines.append(
393
+ f"{_TABLE_TL}{_TABLE_H * 16}{_TABLE_H * 16}{_TABLE_H * 18}{_TABLE_H * 20}"
394
+ f"{_TABLE_TR}"
395
+ )
396
+
397
+ title = "DEPLOYMENT AUDIT REPORT"
398
+ padding = (70 - len(title)) // 2
399
+ lines.append(
400
+ f"{_TABLE_V} {' ' * padding}{_bold(title)}{' ' * (70 - padding - len(title))} {_TABLE_V}"
401
+ )
402
+ lines.append(
403
+ f"{_TABLE_V} {' ' * (70 - len(start_dt))}{start_dt} {_TABLE_V}"
404
+ )
405
+ lines.append(
406
+ f"{_TABLE_TM}{_TABLE_H * 70}{_TABLE_H * 0}"
407
+ )
408
+
409
+ lines.append(self._row("Category", "", "Status | Detail"))
410
+ lines.append(
411
+ f"{_TABLE_TML}{_TABLE_MH * 70}"
412
+ )
413
+
414
+ lines.append(self._row("", "", ""))
415
+
416
+ # ── Backup section ──
417
+ lines.append(
418
+ self._row(
419
+ "Backup Started",
420
+ "passed" if f["backup_started"] else "failed",
421
+ f["backup_started"] or "\u2014",
422
+ )
423
+ )
424
+ lines.append(
425
+ self._row(
426
+ "Backup Completed",
427
+ "passed" if f["backup_completed"] else "failed",
428
+ f["backup_completed"] or "\u2014",
429
+ )
430
+ )
431
+ lines.append(
432
+ self._row(
433
+ "Files Backed Up",
434
+ "passed" if f["backup_files"] > 0 else "failed",
435
+ str(f["backup_files"]),
436
+ )
437
+ )
438
+ lines.append(
439
+ self._row(
440
+ "Total Backup Size",
441
+ "passed" if f["backup_size"] > 0 else "failed",
442
+ _human_size(f["backup_size"]),
443
+ )
444
+ )
445
+
446
+ lines.append(self._sep())
447
+
448
+ # ── Deployment section ──
449
+ lines.append(
450
+ self._row(
451
+ "Commit Status",
452
+ f["commit_status"],
453
+ f["commit_hash"] or "\u2014",
454
+ )
455
+ )
456
+ lines.append(
457
+ self._row(
458
+ "Push Status",
459
+ f["push_status"],
460
+ _ok("Pushed") if f["push_status"] == "passed" else _fail("Failed"),
461
+ )
462
+ )
463
+ dep_icon = "passed" if f["deploy_status"] == "passed" else "failed"
464
+ lines.append(
465
+ self._row(
466
+ "HF Space Deployment",
467
+ dep_icon,
468
+ f["deploy_duration"] or "\u2014",
469
+ )
470
+ )
471
+
472
+ lines.append(self._sep())
473
+
474
+ # ── Restore section ──
475
+ lines.append(
476
+ self._row(
477
+ "Data Restoration",
478
+ f["restore_status"],
479
+ _ok("Restored") if f["restore_status"] == "passed" else _fail("Failed"),
480
+ )
481
+ )
482
+ lines.append(
483
+ self._row(
484
+ "Files Restored",
485
+ "passed" if f["restore_files"] > 0 else "failed",
486
+ str(f["restore_files"]),
487
+ )
488
+ )
489
+ lines.append(
490
+ self._row(
491
+ "Verification Status",
492
+ f["verification_status"],
493
+ _ok("Verified") if f["verification_status"] == "passed" else _fail("Failed"),
494
+ )
495
+ )
496
+
497
+ lines.append(self._sep())
498
+
499
+ # ── Summary section ──
500
+ lines.append(
501
+ self._row(
502
+ "Total Execution Time",
503
+ "passed",
504
+ f.get("total_duration", ""),
505
+ )
506
+ )
507
+ lines.append(
508
+ self._row(
509
+ "Errors",
510
+ "failed" if err_count > 0 else "passed",
511
+ str(err_count),
512
+ )
513
+ )
514
+ lines.append(
515
+ self._row(
516
+ "Warnings",
517
+ "passed" if warn_count == 0 else "skipped",
518
+ str(warn_count),
519
+ )
520
+ )
521
+
522
+ lines.append(
523
+ f"{_TABLE_BL}{_TABLE_H * 16}{_TABLE_H * 16}{_TABLE_H * 18}{_TABLE_H * 20}"
524
+ f"{_TABLE_BR}"
525
+ )
526
+
527
+ for line in lines:
528
+ print(line)
529
+
530
+ if err_count > 0:
531
+ print(f"\n{_fail('Errors:')}")
532
+ for e in f["errors"]:
533
+ print(f" {_fail(_BULLET)} {e}")
534
+
535
+ if warn_count > 0:
536
+ print(f"\n{_warn('Warnings:')}")
537
+ for w in f["warnings"]:
538
+ print(f" {_warn(_BULLET)} {w}")
539
+
540
+
541
+ def run_deployment(args: argparse.Namespace, env: dict[str, str]) -> int:
542
+ audit = AuditReport()
543
+ cwd = os.getcwd()
544
+ persistence_dir = Path(cwd) / "persistence"
545
+ persistence_dir.mkdir(parents=True, exist_ok=True)
546
+
547
+ space_url = args.space_url or env.get("SPACE_URL", "https://aetherbase-llm-ready-data.hf.space")
548
+ api_key = args.api_key or env.get("api_key", env.get("API_KEY", ""))
549
+ hf_token = args.hf_token or env.get("hf_token", env.get("HF_TOKEN", ""))
550
+ skip_backup = args.skip_backup
551
+
552
+ if not api_key:
553
+ print(f" {_fail('ERROR:')} No API key found. Set API_KEY in .env or pass --api-key.")
554
+ return 1
555
+
556
+ if not hf_token:
557
+ print(f" {_warn('WARNING:')} No HF token. Can't verify Space build status. Set HF_TOKEN in .env or pass --hf-token.")
558
+ hf_token = None
559
+
560
+ auth_headers = {
561
+ "Authorization": f"Bearer {api_key}",
562
+ "User-Agent": "deploy.py/1.0",
563
+ }
564
+
565
+ remote_url = git_remote_url(cwd)
566
+ owner, space_name = _parse_hf_space_from_url(remote_url) if remote_url else ("aetherbase", "llm-ready-data")
567
+ hf_api_headers = None
568
+ if hf_token:
569
+ hf_api_headers = {
570
+ "Authorization": f"Bearer {hf_token}",
571
+ "User-Agent": "deploy.py/1.0",
572
+ }
573
+
574
+ # ──────────────────────────────────────────────
575
+ # PHASE 1: BACKUP (remote via API, fallback to local)
576
+ # ──────────────────────────────────────────────
577
+ print(f"\n{_bold('PHASE 1/4: Data Backup')}")
578
+ print(f" {_INFO_ICON} Backing up data from: {space_url}")
579
+
580
+ if skip_backup:
581
+ print(f" {_WARN_ICON} Backup skipped (--skip-backup)")
582
+ audit.set("backup_status", "skipped")
583
+ else:
584
+ backup_file = persistence_dir / BACKUP_ARCHIVE
585
+ backup_url = _build_url(space_url, BACKUP_ENDPOINT)
586
+ audit.set("backup_started", _timestamp())
587
+ backed_up = False
588
+
589
+ # Try remote backup endpoint first
590
+ try:
591
+ status, total_bytes = http_get_stream(backup_url, auth_headers, backup_file, timeout=120)
592
+ if status == 200:
593
+ backed_up = True
594
+ print(f" {_OK_ICON} Remote backup successful")
595
+ except Exception as exc:
596
+ print(f" {_WARN_ICON} Remote backup unavailable: {exc}")
597
+
598
+ # Fall back to local backup
599
+ if not backed_up:
600
+ data_dir = Path(cwd) / "data"
601
+ if data_dir.is_dir():
602
+ print(f" {_INFO_ICON} Falling back to local backup: {data_dir}")
603
+ try:
604
+ buf = io.BytesIO()
605
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
606
+ for path in sorted(data_dir.rglob("*")):
607
+ if path.is_file():
608
+ arcname = path.relative_to(data_dir.parent)
609
+ tar.add(str(path), arcname=str(arcname))
610
+ buf.seek(0)
611
+ backup_file.write_bytes(buf.read())
612
+
613
+ total_bytes = backup_file.stat().st_size
614
+ file_count = 0
615
+ with tarfile.open(backup_file, "r:gz") as tar:
616
+ file_count = sum(1 for m in tar.getmembers() if m.isfile())
617
+
618
+ backed_up = True
619
+ print(f" {_OK_ICON} Local backup saved: {backup_file}")
620
+ except Exception as exc:
621
+ msg = f"Local backup failed: {exc}"
622
+ audit.error(msg)
623
+ print(f" {_FAIL_ICON} {msg}")
624
+ audit.print()
625
+ return 1
626
+ else:
627
+ msg = f"No data found locally ({data_dir}) and remote backup unavailable"
628
+ audit.warn(msg)
629
+ print(f" {_WARN_ICON} {msg}")
630
+ audit.set("backup_files", 0)
631
+ audit.set("backup_size", 0)
632
+ audit.set("backup_completed", _timestamp())
633
+ audit.set("backup_status", "skipped")
634
+
635
+ if backed_up:
636
+ file_count = 0
637
+ try:
638
+ with tarfile.open(backup_file, "r:gz") as tar:
639
+ file_count = sum(1 for m in tar.getmembers() if m.isfile())
640
+ except Exception:
641
+ audit.warn("Could not count files in backup archive")
642
+ file_count = backup_file.stat().st_size
643
+
644
+ audit.set("backup_completed", _timestamp())
645
+ audit.set("backup_files", file_count)
646
+ audit.set("backup_size", total_bytes)
647
+ audit.set("backup_status", "passed")
648
+ print(f" {_OK_ICON} Files: {file_count} | Size: {_human_size(total_bytes)}")
649
+
650
+ # ──────────────────────────────────────────────
651
+ # PHASE 2: GIT COMMIT & PUSH
652
+ # ──────────────────────────────────────────────
653
+ print(f"\n{_bold('PHASE 2/4: Git Commit & Push')}")
654
+
655
+ changed = git_status(cwd)
656
+ if not changed:
657
+ print(f" {_WARN_ICON} No changes to commit (working tree clean)")
658
+ audit.set("commit_status", "passed")
659
+ audit.set("commit_hash", "no-change")
660
+ audit.set("push_status", "passed")
661
+ else:
662
+ print(f" {_INFO_ICON} Staging {len(changed)} file(s)...")
663
+ if not git_add_all(cwd):
664
+ msg = "Git add failed"
665
+ audit.error(msg)
666
+ audit.set("commit_status", "failed")
667
+ print(f" {_FAIL_ICON} {msg}")
668
+ audit.print()
669
+ return 1
670
+
671
+ commit_msg = args.message or f"deploy: auto-deploy {_timestamp()}"
672
+ print(f" {_INFO_ICON} Committing: {commit_msg}")
673
+ ok, sha = git_commit(cwd, commit_msg)
674
+ if not ok:
675
+ msg = f"Git commit failed: {sha}"
676
+ audit.error(msg)
677
+ audit.set("commit_status", "failed")
678
+ print(f" {_FAIL_ICON} {msg}")
679
+ audit.print()
680
+ return 1
681
+
682
+ audit.set("commit_status", "passed")
683
+ audit.set("commit_hash", sha)
684
+ print(f" {_OK_ICON} Committed: {sha}")
685
+
686
+ print(f" {_INFO_ICON} Pushing to origin/main...")
687
+ ok, push_out = git_push(cwd)
688
+ if not ok:
689
+ msg = f"Git push failed"
690
+ audit.error(msg)
691
+ audit.set("push_status", "failed")
692
+ print(f" {_FAIL_ICON} {msg}")
693
+ print(f" {push_out}")
694
+ audit.print()
695
+ return 1
696
+
697
+ audit.set("push_status", "passed")
698
+ print(f" {_OK_ICON} Push successful")
699
+
700
+ # ──────────────────────────────────────────────
701
+ # PHASE 3: WAIT FOR REDEPLOYMENT (via HF API)
702
+ # ──────────────────────────────────────────────
703
+ print(f"\n{_bold('PHASE 3/4: Waiting for HF Space Redeployment')}")
704
+
705
+ api_url = f"{HF_API_SPACES}/{owner}/{space_name}"
706
+ deploy_start = time.time()
707
+ max_wait = args.timeout
708
+ poll_interval = 10
709
+ waited = 0
710
+ deployed = False
711
+ last_error = ""
712
+ seen_building = False
713
+
714
+ print(f" {_INFO_ICON} Tracking {owner}/{space_name} via HF API...")
715
+
716
+ if hf_api_headers:
717
+ initial = http_get_json(api_url, hf_api_headers, timeout=15) or {}
718
+ prev_stage = initial.get("runtime", {}).get("stage", "")
719
+
720
+ while waited < max_wait:
721
+ data = http_get_json(api_url, hf_api_headers, timeout=15)
722
+ stage = (data or {}).get("runtime", {}).get("stage", "")
723
+
724
+ if stage == "BUILDING":
725
+ if not seen_building:
726
+ print(f" {_INFO_ICON} Build started (stage: BUILDING)")
727
+ seen_building = True
728
+
729
+ elif stage == "RUNNING":
730
+ if seen_building:
731
+ print(f" {_OK_ICON} Build complete (stage: RUNNING)")
732
+ deployed = True
733
+ break
734
+ if waited > 30 and prev_stage == "RUNNING":
735
+ print(f" {_OK_ICON} Space is running (may have skipped BUILDING stage)")
736
+ deployed = True
737
+ break
738
+
739
+ elif stage in ("PAUSED", "STOPPED", "NO_APP"):
740
+ last_error = f"Space in unexpected state: {stage}"
741
+ deployed = False
742
+ break
743
+
744
+ prev_stage = stage
745
+ time.sleep(poll_interval)
746
+ waited += poll_interval
747
+ if waited % 30 == 0 and not seen_building:
748
+ print(f" {_INFO_ICON} Still waiting for build to start... ({waited}s)")
749
+ elif waited % 30 == 0 and seen_building:
750
+ print(f" {_INFO_ICON} Still building... ({waited}s)")
751
+
752
+ deploy_duration = time.time() - deploy_start
753
+
754
+ if not hf_api_headers:
755
+ print(f" {_WARN_ICON} No HF token — assuming deployment is underway")
756
+ print(f" {_INFO_ICON} Waiting {max_wait}s for build + startup...")
757
+ time.sleep(max_wait)
758
+ deployed = True
759
+
760
+ if deployed:
761
+ health_url = _build_url(space_url, HEALTH_ENDPOINT)
762
+ try:
763
+ req = urllib.request.Request(health_url, method="GET")
764
+ with urllib.request.urlopen(req, timeout=15) as resp:
765
+ if resp.status == 200:
766
+ print(f" {_OK_ICON} App health check passed (new instance serving)")
767
+ except Exception:
768
+ print(f" {_WARN_ICON} App health check unavailable (may still be starting)")
769
+
770
+ audit.set("deploy_status", "passed")
771
+ audit.set("deploy_duration", _human_duration(deploy_duration))
772
+ print(f" {_OK_ICON} Space redeployed in {_human_duration(deploy_duration)}")
773
+ else:
774
+ audit.set("deploy_status", "failed")
775
+ audit.set("deploy_duration", _human_duration(deploy_duration))
776
+ msg = f"Space did not redeploy within {max_wait}s (last stage: {stage}, error: {last_error})"
777
+ audit.error(msg)
778
+ print(f" {_FAIL_ICON} {msg}")
779
+ audit.print()
780
+ return 1
781
+
782
+ # ──────────────────────────────────────────────
783
+ # PHASE 4: DATA RESTORE
784
+ # ──────────────────────────────────────────────
785
+ print(f"\n{_bold('PHASE 4/4: Data Restoration')}")
786
+
787
+ if skip_backup:
788
+ print(f" {_WARN_ICON} Restore skipped (no backup)")
789
+ audit.set("restore_status", "skipped")
790
+ audit.set("verification_status", "skipped")
791
+ audit.set("restore_files", 0)
792
+ else:
793
+ backup_file = persistence_dir / BACKUP_ARCHIVE
794
+ if not backup_file.is_file():
795
+ msg = f"Backup file not found: {backup_file}"
796
+ audit.error(msg)
797
+ audit.set("restore_status", "failed")
798
+ print(f" {_FAIL_ICON} {msg}")
799
+ audit.print()
800
+ return 1
801
+
802
+ restore_url = _build_url(space_url, RESTORE_ENDPOINT)
803
+ print(f" {_INFO_ICON} Restoring data to: {space_url}")
804
+
805
+ try:
806
+ status, body = http_post_multipart(
807
+ restore_url, backup_file, "file", auth_headers, timeout=300
808
+ )
809
+
810
+ if status == 200:
811
+ try:
812
+ result = json.loads(body)
813
+ restored = result.get("files_restored", 0)
814
+ except Exception:
815
+ restored = 0
816
+
817
+ audit.set("restore_files", max(restored, 1))
818
+ audit.set("restore_status", "passed")
819
+ print(f" {_OK_ICON} Data restored: {restored} files")
820
+
821
+ # Verification
822
+ print(f" {_INFO_ICON} Verifying data restoration...")
823
+ try:
824
+ verify_backup = persistence_dir / "verify_check.tar.gz"
825
+ status_v, _ = http_get_stream(
826
+ _build_url(space_url, BACKUP_ENDPOINT),
827
+ auth_headers,
828
+ verify_backup,
829
+ timeout=120,
830
+ )
831
+
832
+ if status_v == 200:
833
+ with tarfile.open(verify_backup, "r:gz") as tar:
834
+ verify_files = sum(1 for m in tar.getmembers() if m.isfile())
835
+ verify_backup.unlink(missing_ok=True)
836
+
837
+ if verify_files >= audit.fields.get("backup_files", 0) * 0.9:
838
+ audit.set("verification_status", "passed")
839
+ print(f" {_OK_ICON} Verification passed: {verify_files} files found")
840
+ else:
841
+ audit.set("verification_status", "failed")
842
+ audit.warn(
843
+ f"Verification file count ({verify_files}) mismatch with backup ({audit.fields.get('backup_files', 0)})"
844
+ )
845
+ print(f" {_WARN_ICON} Verification: file count mismatch ({verify_files} vs {audit.fields.get('backup_files', 0)})")
846
+ else:
847
+ audit.set("verification_status", "failed")
848
+ audit.warn(f"Verification request returned HTTP {status_v}")
849
+ print(f" {_WARN_ICON} Verification failed (HTTP {status_v})")
850
+ except Exception as exc:
851
+ audit.set("verification_status", "failed")
852
+ audit.warn(f"Verification error: {exc}")
853
+ print(f" {_WARN_ICON} Verification error: {exc}")
854
+ else:
855
+ audit.set("restore_status", "failed")
856
+ msg = f"Restore returned HTTP {status}: {body[:200]}"
857
+ audit.error(msg)
858
+ print(f" {_FAIL_ICON} {msg}")
859
+ audit.print()
860
+ return 1
861
+
862
+ except Exception as exc:
863
+ audit.set("restore_status", "failed")
864
+ msg = f"Restore failed: {exc}"
865
+ audit.error(msg)
866
+ print(f" {_FAIL_ICON} {msg}")
867
+ audit.print()
868
+ return 1
869
+
870
+ # ──────────────────────────────────────────────
871
+ # SUMMARY
872
+ # ──────────────────────────────────────────────
873
+ print(f"\n{_bold('=' * 70)}")
874
+ audit.print()
875
+
876
+ has_errors = len(audit.fields["errors"]) > 0
877
+ if has_errors:
878
+ print(f"\n{_fail('Deployment completed with errors.')}")
879
+ return 1
880
+ else:
881
+ print(f"\n{_ok('Deployment completed successfully.')}")
882
+ return 0
883
+
884
+
885
+ # ─────────────────────────────────────────────────────────────
886
+ # CLI entrypoint
887
+ # ─────────────────────────────────────────────────────────────
888
+
889
+
890
+ def main():
891
+ parser = argparse.ArgumentParser(
892
+ description="Deploy to Hugging Face Spaces with persistent data handling.",
893
+ formatter_class=argparse.RawDescriptionHelpFormatter,
894
+ epilog=(
895
+ "Environment variables (from .env or process env):\n"
896
+ " API_KEY API key for the Hugging Face Space\n"
897
+ " SPACE_URL Base URL of the deployed Space\n"
898
+ " HF_TOKEN Hugging Face API token (for build tracking)\n"
899
+ ),
900
+ )
901
+ parser.add_argument(
902
+ "--space-url",
903
+ default="",
904
+ help="Base URL of the Hugging Face Space (e.g. https://aetherbase-llm-ready-data.hf.space)",
905
+ )
906
+ parser.add_argument(
907
+ "--api-key",
908
+ default="",
909
+ help="API key for authenticating with the Space",
910
+ )
911
+ parser.add_argument(
912
+ "--message", "-m",
913
+ default="",
914
+ help="Git commit message",
915
+ )
916
+ parser.add_argument(
917
+ "--timeout",
918
+ type=int,
919
+ default=300,
920
+ help="Maximum wait time (seconds) for Space redeployment (default: 300)",
921
+ )
922
+ parser.add_argument(
923
+ "--hf-token",
924
+ default="",
925
+ help="Hugging Face API token (for tracking Space build status)",
926
+ )
927
+ parser.add_argument(
928
+ "--skip-backup",
929
+ action="store_true",
930
+ help="Skip the backup and restore phases",
931
+ )
932
+ parser.add_argument(
933
+ "--env-file",
934
+ default=".env",
935
+ help="Path to .env file (default: .env)",
936
+ )
937
+
938
+ args = parser.parse_args()
939
+ env = load_env(args.env_file)
940
+
941
+ # Merge with process environment (process env takes precedence)
942
+ for key in ("API_KEY", "SPACE_URL", "HF_TOKEN"):
943
+ if os.environ.get(key):
944
+ env[key] = os.environ[key]
945
+
946
+ try:
947
+ rc = run_deployment(args, env)
948
+ except KeyboardInterrupt:
949
+ print(f"\n{_warn('Deployment interrupted by user.')}")
950
+ rc = 130
951
+ except Exception as exc:
952
+ print(f"\n{_fail('Unexpected error: ' + str(exc))}")
953
+ rc = 1
954
+
955
+ sys.exit(rc)
956
+
957
+
958
+ if __name__ == "__main__":
959
+ main()
tests/test_webhook_socket.py ADDED
@@ -0,0 +1,422 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from typing import AsyncGenerator
11
+
12
+ import pytest
13
+
14
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15
+
16
+ from app.services.webhook_socket_service import ChannelManager, sign_payload, verify_signature
17
+
18
+ API_KEY = "changeme"
19
+ AUTH_HEADER = {"Authorization": f"Bearer {API_KEY}"}
20
+ BASE = "http://localhost:7860/api/v1"
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Unit tests for ChannelManager
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class TestChannelManager:
28
+ def test_create_channel(self):
29
+ mgr = ChannelManager()
30
+ ch = mgr.create_channel()
31
+ assert ch.channel_id is not None
32
+ assert len(ch.channel_id) == 16
33
+ assert ch.buffer_size == 0
34
+ assert ch.secret is None
35
+
36
+ def test_create_channel_custom_id(self):
37
+ mgr = ChannelManager()
38
+ ch = mgr.create_channel(channel_id="my-channel")
39
+ assert ch.channel_id == "my-channel"
40
+
41
+ def test_create_channel_with_secret_and_buffer(self):
42
+ mgr = ChannelManager()
43
+ ch = mgr.create_channel(channel_id="secure", secret="s3cret", buffer_size=10)
44
+ assert ch.secret == "s3cret"
45
+ assert ch.buffer_size == 10
46
+
47
+ def test_get_channel(self):
48
+ mgr = ChannelManager()
49
+ mgr.create_channel(channel_id="abc")
50
+ assert mgr.get_channel("abc") is not None
51
+ assert mgr.get_channel("nonexistent") is None
52
+
53
+ def test_delete_channel(self):
54
+ mgr = ChannelManager()
55
+ mgr.create_channel(channel_id="del-me")
56
+ assert mgr.delete_channel("del-me") is True
57
+ assert mgr.get_channel("del-me") is None
58
+ assert mgr.delete_channel("del-me") is False
59
+
60
+ def test_create_duplicate_id(self):
61
+ mgr = ChannelManager()
62
+ mgr.create_channel(channel_id="dup")
63
+ ch2 = mgr.create_channel(channel_id="dup")
64
+ # Should overwrite
65
+ assert mgr.get_channel("dup") is ch2
66
+
67
+ def test_default_buffer_from_manager(self):
68
+ mgr = ChannelManager(default_buffer=25)
69
+ ch = mgr.create_channel()
70
+ assert ch.buffer_size == 25
71
+
72
+ def test_publish_nonexistent_channel(self):
73
+ mgr = ChannelManager()
74
+ result = asyncio.run(mgr.publish("no-such-channel", {"hello": "world"}))
75
+ assert result == -1
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_publish_and_buffer(self):
79
+ mgr = ChannelManager()
80
+ ch = mgr.create_channel(channel_id="buf-test", buffer_size=3)
81
+
82
+ await mgr.publish("buf-test", {"n": 1})
83
+ await mgr.publish("buf-test", {"n": 2})
84
+ await mgr.publish("buf-test", {"n": 3})
85
+ await mgr.publish("buf-test", {"n": 4})
86
+
87
+ assert len(ch.history) == 3
88
+ assert ch.history[0]["payload"]["n"] == 2
89
+ assert ch.history[2]["payload"]["n"] == 4
90
+ assert ch.message_count == 4
91
+
92
+ def test_stats(self):
93
+ mgr = ChannelManager()
94
+ mgr.create_channel(channel_id="a")
95
+ mgr.create_channel(channel_id="b")
96
+ stats = mgr.stats()
97
+ assert stats["channels"] == 2
98
+ assert stats["total_subscribers"] == 0
99
+
100
+ def test_sign_and_verify(self):
101
+ secret = "my-secret"
102
+ body = b'{"hello":"world"}'
103
+ sig = sign_payload(secret, body)
104
+ assert sig.startswith("sha256=")
105
+ assert verify_signature(secret, body, sig) is True
106
+ assert verify_signature(secret, body, "sha256=bad") is False
107
+ assert verify_signature("wrong-secret", body, sig) is False
108
+
109
+ def test_sign_constant_result(self):
110
+ secret = "test"
111
+ body = b"data"
112
+ sig1 = sign_payload(secret, body)
113
+ sig2 = sign_payload(secret, body)
114
+ assert sig1 == sig2
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # Integration tests (requires running server @ localhost:7860)
119
+ # ---------------------------------------------------------------------------
120
+
121
+ pytestmark_integration = pytest.mark.skipif(
122
+ not os.environ.get("RUN_INTEGRATION_TESTS"),
123
+ reason="Set RUN_INTEGRATION_TESTS=1 to run integration tests",
124
+ )
125
+
126
+
127
+ def _sign(body: bytes, secret: str) -> str:
128
+ return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
129
+
130
+
131
+ @pytest.mark.skipif(
132
+ not os.environ.get("RUN_INTEGRATION_TESTS"),
133
+ reason="Set RUN_INTEGRATION_TESTS=1 to run integration tests",
134
+ )
135
+ class TestWebhookSocketIntegration:
136
+ @pytest.fixture(autouse=True)
137
+ async def _setup(self):
138
+ import httpx
139
+
140
+ async with httpx.AsyncClient(base_url=BASE) as client:
141
+ self.client = client
142
+ yield
143
+
144
+ async def _create_channel(self, **kwargs) -> dict:
145
+ resp = await self.client.post("/channels", json=kwargs, headers=AUTH_HEADER)
146
+ assert resp.status_code == 201
147
+ return resp.json()
148
+
149
+ async def _delete_channel(self, channel_id: str):
150
+ resp = await self.client.delete(f"/channels/{channel_id}", headers=AUTH_HEADER)
151
+ return resp.status_code == 200
152
+
153
+ async def test_health(self):
154
+ resp = await self.client.get("/health")
155
+ assert resp.status_code == 200
156
+ data = resp.json()
157
+ assert data["success"] is True
158
+
159
+ async def test_create_and_list_channels(self):
160
+ ch = await self._create_channel(channel_id="test-list", buffer_size=5)
161
+ assert ch["channel_id"] == "test-list"
162
+
163
+ resp = await self.client.get("/channels", headers=AUTH_HEADER)
164
+ assert resp.status_code == 200
165
+ data = resp.json()
166
+ assert any(c["channel_id"] == "test-list" for c in data["channels"])
167
+
168
+ await self._delete_channel("test-list")
169
+
170
+ async def test_create_channel_no_auth(self):
171
+ resp = await self.client.post("/channels", json={})
172
+ assert resp.status_code == 403
173
+
174
+ async def test_create_duplicate_channel(self):
175
+ await self._create_channel(channel_id="dup-test")
176
+ resp = await self.client.post("/channels", json={"channel_id": "dup-test"}, headers=AUTH_HEADER)
177
+ assert resp.status_code == 409
178
+ await self._delete_channel("dup-test")
179
+
180
+ async def test_channel_info(self):
181
+ await self._create_channel(channel_id="info-test")
182
+ resp = await self.client.get("/channels/info-test", headers=AUTH_HEADER)
183
+ assert resp.status_code == 200
184
+ data = resp.json()
185
+ assert data["channel_id"] == "info-test"
186
+ assert data["subscribers"] == 0
187
+ assert data["messages"] == 0
188
+ await self._delete_channel("info-test")
189
+
190
+ async def test_channel_info_not_found(self):
191
+ resp = await self.client.get("/channels/no-such", headers=AUTH_HEADER)
192
+ assert resp.status_code == 404
193
+
194
+ async def test_delete_channel(self):
195
+ await self._create_channel(channel_id="delete-me")
196
+ resp = await self.client.delete("/channels/delete-me", headers=AUTH_HEADER)
197
+ assert resp.status_code == 200
198
+ assert resp.json()["deleted"] == "delete-me"
199
+
200
+ resp = await self.client.get("/channels/delete-me", headers=AUTH_HEADER)
201
+ assert resp.status_code == 404
202
+
203
+ async def test_delete_channel_not_found(self):
204
+ resp = await self.client.delete("/channels/no-such", headers=AUTH_HEADER)
205
+ assert resp.status_code == 404
206
+
207
+ async def test_webhook_delivers_to_ws(self):
208
+ ch = await self._create_channel(channel_id="ws-deliver", buffer_size=5)
209
+ cid = ch["channel_id"]
210
+
211
+ import websockets
212
+
213
+ ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"
214
+
215
+ async with websockets.connect(ws_url) as ws:
216
+ connected = json.loads(await ws.recv())
217
+ assert connected["event"] == "connected"
218
+ assert connected["channel"] == cid
219
+
220
+ payload = {"msg": "hello from webhook", "num": 42}
221
+ resp = await self.client.post(f"/webhook/{cid}", json=payload)
222
+ assert resp.status_code == 200
223
+ wh_data = resp.json()
224
+ assert wh_data["status"] == "delivered"
225
+ assert wh_data["subscribers_notified"] == 1
226
+
227
+ received = json.loads(await ws.recv())
228
+ assert received["event"] == "message"
229
+ assert received["channel"] == cid
230
+ assert received["payload"] == payload
231
+
232
+ await self._delete_channel(cid)
233
+
234
+ async def test_webhook_no_subscribers(self):
235
+ ch = await self._create_channel(channel_id="no-subs")
236
+ resp = await self.client.post(f"/webhook/{ch['channel_id']}", json={"data": 1})
237
+ assert resp.status_code == 200
238
+ assert resp.json()["subscribers_notified"] == 0
239
+ await self._delete_channel(ch["channel_id"])
240
+
241
+ async def test_webhook_not_found(self):
242
+ resp = await self.client.post("/webhook/no-such", json={"x": 1})
243
+ assert resp.status_code == 404
244
+
245
+ async def test_hook_alias(self):
246
+ ch = await self._create_channel(channel_id="hook-alias")
247
+ resp = await self.client.post(f"/hook/{ch['channel_id']}", json={"test": True})
248
+ assert resp.status_code == 200
249
+ assert resp.json()["status"] == "delivered"
250
+ await self._delete_channel(ch["channel_id"])
251
+
252
+ async def test_hmac_signed_webhook(self):
253
+ secret = "hmac-test-secret"
254
+ ch = await self._create_channel(channel_id="hmac-test", secret=secret)
255
+ cid = ch["channel_id"]
256
+
257
+ payload = b'{"signed": "data"}'
258
+ sig = _sign(payload, secret)
259
+
260
+ resp = await self.client.post(
261
+ f"/webhook/{cid}",
262
+ content=payload,
263
+ headers={"Content-Type": "application/json", "X-Signature-256": sig},
264
+ )
265
+ assert resp.status_code == 200, resp.text
266
+
267
+ resp_bad = await self.client.post(
268
+ f"/webhook/{cid}",
269
+ content=payload,
270
+ headers={"Content-Type": "application/json", "X-Signature-256": "sha256=bad"},
271
+ )
272
+ assert resp_bad.status_code == 401
273
+
274
+ resp_no_sig = await self.client.post(
275
+ f"/webhook/{cid}",
276
+ content=payload,
277
+ headers={"Content-Type": "application/json"},
278
+ )
279
+ assert resp_no_sig.status_code == 401
280
+
281
+ await self._delete_channel(cid)
282
+
283
+ async def test_webhook_form_urlencoded(self):
284
+ ch = await self._create_channel(channel_id="form-test")
285
+ cid = ch["channel_id"]
286
+
287
+ resp = await self.client.post(
288
+ f"/webhook/{cid}",
289
+ data={"field1": "value1", "field2": "value2"},
290
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
291
+ )
292
+ assert resp.status_code == 200
293
+ assert resp.json()["status"] == "delivered"
294
+
295
+ await self._delete_channel(cid)
296
+
297
+ async def test_webhook_raw_text(self):
298
+ ch = await self._create_channel(channel_id="raw-test")
299
+ cid = ch["channel_id"]
300
+
301
+ resp = await self.client.post(
302
+ f"/webhook/{cid}",
303
+ content="just some raw text",
304
+ headers={"Content-Type": "text/plain"},
305
+ )
306
+ assert resp.status_code == 200
307
+
308
+ await self._delete_channel(cid)
309
+
310
+ async def test_ws_replay_buffer(self):
311
+ ch = await self._create_channel(channel_id="replay-test", buffer_size=3)
312
+ cid = ch["channel_id"]
313
+
314
+ await self.client.post(f"/webhook/{cid}", json={"n": 1})
315
+ await self.client.post(f"/webhook/{cid}", json={"n": 2})
316
+ await self.client.post(f"/webhook/{cid}", json={"n": 3})
317
+
318
+ import websockets
319
+
320
+ ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"
321
+ async with websockets.connect(ws_url) as ws:
322
+ connected = json.loads(await ws.recv())
323
+ assert connected["event"] == "connected"
324
+ assert connected["buffered"] == 3
325
+
326
+ for expected_n in [1, 2, 3]:
327
+ msg = json.loads(await ws.recv())
328
+ assert msg["payload"]["n"] == expected_n
329
+
330
+ await self._delete_channel(cid)
331
+
332
+ async def test_ws_auth_with_secret(self):
333
+ ch = await self._create_channel(channel_id="ws-auth-test", secret="topsecret")
334
+ cid = ch["channel_id"]
335
+
336
+ import websockets
337
+
338
+ ws_url = f"ws://localhost:7860/api/v1/ws/{cid}"
339
+
340
+ async with websockets.connect(f"{ws_url}?secret=topsecret") as ws:
341
+ msg = json.loads(await ws.recv())
342
+ assert msg["event"] == "connected"
343
+
344
+ async with websockets.connect(ws_url) as ws:
345
+ msg = json.loads(await ws.recv())
346
+ assert msg["event"] == "error"
347
+
348
+ await self._delete_channel(cid)
349
+
350
+ async def test_ws_channel_not_found(self):
351
+ import websockets
352
+
353
+ async with websockets.connect("ws://localhost:7860/api/v1/ws/does-not-exist") as ws:
354
+ msg = json.loads(await ws.recv())
355
+ assert msg["event"] == "error"
356
+ assert "channel not found" in msg["message"]
357
+
358
+ async def test_stats_endpoint(self):
359
+ resp = await self.client.get("/webhook-socket/stats", headers=AUTH_HEADER)
360
+ assert resp.status_code == 200
361
+ data = resp.json()
362
+ assert "channels" in data
363
+ assert "total_messages" in data
364
+ assert "total_subscribers" in data
365
+
366
+ async def test_full_lifecycle(self):
367
+ cid = "lifecycle-test"
368
+
369
+ ch = await self._create_channel(channel_id=cid, buffer_size=10, secret="life-secret")
370
+ assert ch["channel_id"] == cid
371
+
372
+ info_resp = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
373
+ assert info_resp.status_code == 200
374
+ assert info_resp.json()["messages"] == 0
375
+
376
+ import websockets
377
+
378
+ ws_url = f"ws://localhost:7860/api/v1/ws/{cid}?secret=life-secret"
379
+ async with websockets.connect(ws_url) as ws:
380
+ connected = json.loads(await ws.recv())
381
+ assert connected["event"] == "connected"
382
+
383
+ payload = {"event_type": "push", "data": {"ref": "main"}}
384
+ sig = _sign(json.dumps(payload).encode(), "life-secret")
385
+ resp = await self.client.post(
386
+ f"/webhook/{cid}",
387
+ json=payload,
388
+ headers={"X-Signature-256": sig, "X-GitHub-Event": "push"},
389
+ )
390
+ assert resp.status_code == 200
391
+ wh = resp.json()
392
+ assert wh["subscribers_notified"] == 1
393
+
394
+ received = json.loads(await ws.recv())
395
+ assert received["event"] == "message"
396
+ assert received["payload"]["event_type"] == "push"
397
+ assert received["headers"]["X-GitHub-Event"] == "push"
398
+
399
+ stats_resp = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
400
+ assert stats_resp.json()["messages"] == 1
401
+
402
+ await self._delete_channel(cid)
403
+
404
+ not_found = await self.client.get(f"/channels/{cid}", headers=AUTH_HEADER)
405
+ assert not_found.status_code == 404
406
+
407
+
408
+ # ---------------------------------------------------------------------------
409
+ # Runner (standalone)
410
+ # ---------------------------------------------------------------------------
411
+
412
+ if __name__ == "__main__":
413
+ import subprocess
414
+ import sys as _sys
415
+
416
+ os.environ["RUN_INTEGRATION_TESTS"] = "1"
417
+ _sys.exit(
418
+ subprocess.run(
419
+ [_sys.executable, "-m", "pytest", __file__, "-v", "--tb=short"],
420
+ cwd=os.path.join(os.path.dirname(__file__), ".."),
421
+ ).returncode
422
+ )