Spaces:
Sleeping
Sleeping
feat: add /logs/stream SSE endpoint for real-time log streaming
Browse filesHF's runtime log API doesn't capture Docker SDK container output
(confirmed: stdout, stderr, /proc/1/fd/*, Python PID 1 all fail).
Added our own SSE streamer at /logs/stream that mimics HF's format:
curl -N https://tao-shen-huggingrun.hf.space/logs/stream
Also available as static file: curl .../logs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Dockerfile +2 -3
- scripts/entrypoint_wrapper.py +0 -84
- ubuntu-server/log_streamer.py +74 -0
- ubuntu-server/nginx.conf +13 -2
- ubuntu-server/start-server.sh +18 -5
Dockerfile
CHANGED
|
@@ -47,6 +47,7 @@ RUN mkdir -p /data
|
|
| 47 |
COPY ubuntu-server/nginx.conf /etc/nginx/nginx.conf
|
| 48 |
COPY ubuntu-server/ws-ssh-bridge.py /opt/ws-ssh-bridge.py
|
| 49 |
COPY ubuntu-server/git_sync_daemon.py /opt/git_sync_daemon.py
|
|
|
|
| 50 |
COPY ubuntu-server/start-server.sh /opt/start-server.sh
|
| 51 |
COPY scripts /scripts
|
| 52 |
RUN chmod +x /scripts/entrypoint.sh /scripts/entrypoint_wrapper.py /opt/start-server.sh
|
|
@@ -58,6 +59,4 @@ ENV SSH_PORT=2222
|
|
| 58 |
|
| 59 |
# Run as root (needed for: apt install persistence, bind mounts, sshd)
|
| 60 |
EXPOSE 7860
|
| 61 |
-
|
| 62 |
-
# The wrapper tails /var/log/huggingrun.log β stdout/stderr for HF to capture
|
| 63 |
-
ENTRYPOINT ["python3", "/scripts/entrypoint_wrapper.py"]
|
|
|
|
| 47 |
COPY ubuntu-server/nginx.conf /etc/nginx/nginx.conf
|
| 48 |
COPY ubuntu-server/ws-ssh-bridge.py /opt/ws-ssh-bridge.py
|
| 49 |
COPY ubuntu-server/git_sync_daemon.py /opt/git_sync_daemon.py
|
| 50 |
+
COPY ubuntu-server/log_streamer.py /opt/log_streamer.py
|
| 51 |
COPY ubuntu-server/start-server.sh /opt/start-server.sh
|
| 52 |
COPY scripts /scripts
|
| 53 |
RUN chmod +x /scripts/entrypoint.sh /scripts/entrypoint_wrapper.py /opt/start-server.sh
|
|
|
|
| 59 |
|
| 60 |
# Run as root (needed for: apt install persistence, bind mounts, sshd)
|
| 61 |
EXPOSE 7860
|
| 62 |
+
ENTRYPOINT ["/scripts/entrypoint.sh"]
|
|
|
|
|
|
scripts/entrypoint_wrapper.py
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Python PID 1 wrapper for HF Spaces log compatibility.
|
| 4 |
-
HF's runtime log API captures Python stdout/stderr better than bash.
|
| 5 |
-
This wrapper stays as PID 1, runs entrypoint.sh as a child,
|
| 6 |
-
and mirrors all log output to stdout/stderr.
|
| 7 |
-
"""
|
| 8 |
-
import subprocess
|
| 9 |
-
import sys
|
| 10 |
-
import os
|
| 11 |
-
import signal
|
| 12 |
-
import time
|
| 13 |
-
import threading
|
| 14 |
-
|
| 15 |
-
LOGFILE = "/var/log/huggingrun.log"
|
| 16 |
-
|
| 17 |
-
def log(msg):
|
| 18 |
-
"""Write to stdout, stderr, and logfile simultaneously."""
|
| 19 |
-
line = f"{msg}\n"
|
| 20 |
-
sys.stdout.write(line)
|
| 21 |
-
sys.stdout.flush()
|
| 22 |
-
sys.stderr.write(line)
|
| 23 |
-
sys.stderr.flush()
|
| 24 |
-
try:
|
| 25 |
-
with open(LOGFILE, "a") as f:
|
| 26 |
-
f.write(line)
|
| 27 |
-
except Exception:
|
| 28 |
-
pass
|
| 29 |
-
|
| 30 |
-
def tail_logfile():
|
| 31 |
-
"""Background thread: tail the logfile and print new lines to stdout/stderr."""
|
| 32 |
-
# Wait for logfile to exist
|
| 33 |
-
while not os.path.exists(LOGFILE):
|
| 34 |
-
time.sleep(0.5)
|
| 35 |
-
|
| 36 |
-
with open(LOGFILE, "r") as f:
|
| 37 |
-
# Skip to current end
|
| 38 |
-
f.seek(0, 2)
|
| 39 |
-
while True:
|
| 40 |
-
line = f.readline()
|
| 41 |
-
if line:
|
| 42 |
-
line = line.rstrip("\n")
|
| 43 |
-
sys.stdout.write(f"{line}\n")
|
| 44 |
-
sys.stdout.flush()
|
| 45 |
-
sys.stderr.write(f"{line}\n")
|
| 46 |
-
sys.stderr.flush()
|
| 47 |
-
else:
|
| 48 |
-
time.sleep(0.3)
|
| 49 |
-
|
| 50 |
-
if __name__ == "__main__":
|
| 51 |
-
log("========================================")
|
| 52 |
-
log("[wrapper] HuggingRun Python PID 1 wrapper")
|
| 53 |
-
log(f"[wrapper] PID={os.getpid()}, Date={time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())}")
|
| 54 |
-
log(f"[wrapper] Python {sys.version.split()[0]}")
|
| 55 |
-
log("========================================")
|
| 56 |
-
|
| 57 |
-
# Start background tailer: reads logfile and echos to stdout/stderr
|
| 58 |
-
# This ensures all log output (from bash scripts, python daemons) gets
|
| 59 |
-
# printed through PID 1's stdout/stderr for HF to capture
|
| 60 |
-
tailer = threading.Thread(target=tail_logfile, daemon=True)
|
| 61 |
-
tailer.start()
|
| 62 |
-
|
| 63 |
-
# Run entrypoint.sh as child process
|
| 64 |
-
proc = subprocess.Popen(
|
| 65 |
-
["/bin/bash", "/scripts/entrypoint.sh"],
|
| 66 |
-
stdout=subprocess.DEVNULL, # entrypoint writes to logfile
|
| 67 |
-
stderr=subprocess.DEVNULL, # entrypoint writes to logfile
|
| 68 |
-
)
|
| 69 |
-
|
| 70 |
-
# Forward signals to child
|
| 71 |
-
def forward_signal(sig, frame):
|
| 72 |
-
log(f"[wrapper] Signal {sig} received, forwarding to child PID={proc.pid}")
|
| 73 |
-
try:
|
| 74 |
-
proc.send_signal(sig)
|
| 75 |
-
except ProcessLookupError:
|
| 76 |
-
pass
|
| 77 |
-
|
| 78 |
-
signal.signal(signal.SIGTERM, forward_signal)
|
| 79 |
-
signal.signal(signal.SIGINT, forward_signal)
|
| 80 |
-
|
| 81 |
-
# Wait for child to exit
|
| 82 |
-
rc = proc.wait()
|
| 83 |
-
log(f"[wrapper] Child exited with code {rc}")
|
| 84 |
-
sys.exit(rc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ubuntu-server/log_streamer.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
SSE log streamer β serves /var/log/huggingrun.log as Server-Sent Events.
|
| 4 |
+
Mimics HF's runtime log API format so you can use the same curl -N pattern.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
curl -N https://tao-shen-huggingrun.hf.space/logs/stream
|
| 8 |
+
"""
|
| 9 |
+
import http.server
|
| 10 |
+
import time
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
from datetime import datetime, timezone
|
| 14 |
+
|
| 15 |
+
LOGFILE = "/var/log/huggingrun.log"
|
| 16 |
+
PORT = 7863
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SSEHandler(http.server.BaseHTTPRequestHandler):
|
| 20 |
+
def log_message(self, format, *args):
|
| 21 |
+
pass # suppress access logs
|
| 22 |
+
|
| 23 |
+
def do_GET(self):
|
| 24 |
+
if self.path == "/stream":
|
| 25 |
+
self.send_response(200)
|
| 26 |
+
self.send_header("Content-Type", "text/event-stream")
|
| 27 |
+
self.send_header("Cache-Control", "no-cache")
|
| 28 |
+
self.send_header("Connection", "keep-alive")
|
| 29 |
+
self.send_header("Access-Control-Allow-Origin", "*")
|
| 30 |
+
self.end_headers()
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
# Send existing log content first (history)
|
| 34 |
+
if os.path.exists(LOGFILE):
|
| 35 |
+
with open(LOGFILE, "r") as f:
|
| 36 |
+
for line in f:
|
| 37 |
+
line = line.rstrip("\n")
|
| 38 |
+
if line:
|
| 39 |
+
event = json.dumps({
|
| 40 |
+
"data": line + "\n",
|
| 41 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 42 |
+
})
|
| 43 |
+
self.wfile.write(f"data: {event}\n\n".encode())
|
| 44 |
+
self.wfile.flush()
|
| 45 |
+
|
| 46 |
+
# Then tail for new lines
|
| 47 |
+
with open(LOGFILE, "r") as f:
|
| 48 |
+
f.seek(0, 2) # end of file
|
| 49 |
+
while True:
|
| 50 |
+
line = f.readline()
|
| 51 |
+
if line:
|
| 52 |
+
line = line.rstrip("\n")
|
| 53 |
+
if line:
|
| 54 |
+
event = json.dumps({
|
| 55 |
+
"data": line + "\n",
|
| 56 |
+
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 57 |
+
})
|
| 58 |
+
self.wfile.write(f"data: {event}\n\n".encode())
|
| 59 |
+
self.wfile.flush()
|
| 60 |
+
else:
|
| 61 |
+
# Send keep-alive comment every 15s
|
| 62 |
+
self.wfile.write(b": keep-alive\n\n")
|
| 63 |
+
self.wfile.flush()
|
| 64 |
+
time.sleep(1)
|
| 65 |
+
except (BrokenPipeError, ConnectionResetError):
|
| 66 |
+
pass
|
| 67 |
+
else:
|
| 68 |
+
self.send_response(404)
|
| 69 |
+
self.end_headers()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
if __name__ == "__main__":
|
| 73 |
+
server = http.server.HTTPServer(("127.0.0.1", PORT), SSEHandler)
|
| 74 |
+
server.serve_forever()
|
ubuntu-server/nginx.conf
CHANGED
|
@@ -33,13 +33,24 @@ http {
|
|
| 33 |
proxy_send_timeout 86400;
|
| 34 |
}
|
| 35 |
|
| 36 |
-
# /logs β
|
| 37 |
-
location /logs {
|
| 38 |
default_type text/plain;
|
| 39 |
add_header Cache-Control "no-cache, no-store";
|
| 40 |
alias /var/log/huggingrun.log;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
# Everything else β ttyd web terminal (on 7681)
|
| 44 |
location / {
|
| 45 |
proxy_pass http://127.0.0.1:7681;
|
|
|
|
| 33 |
proxy_send_timeout 86400;
|
| 34 |
}
|
| 35 |
|
| 36 |
+
# /logs β full log file (static)
|
| 37 |
+
location = /logs {
|
| 38 |
default_type text/plain;
|
| 39 |
add_header Cache-Control "no-cache, no-store";
|
| 40 |
alias /var/log/huggingrun.log;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
# /logs/stream β SSE real-time log stream (like HF's logs/run API)
|
| 44 |
+
location /logs/stream {
|
| 45 |
+
proxy_pass http://127.0.0.1:7863/stream;
|
| 46 |
+
proxy_http_version 1.1;
|
| 47 |
+
proxy_set_header Connection "";
|
| 48 |
+
proxy_buffering off;
|
| 49 |
+
proxy_cache off;
|
| 50 |
+
chunked_transfer_encoding off;
|
| 51 |
+
proxy_read_timeout 86400;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
# Everything else β ttyd web terminal (on 7681)
|
| 55 |
location / {
|
| 56 |
proxy_pass http://127.0.0.1:7681;
|
ubuntu-server/start-server.sh
CHANGED
|
@@ -59,6 +59,17 @@ else
|
|
| 59 |
log "[ubuntu] [FAILED] sshd failed to start"
|
| 60 |
fi
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
# ββ WebSocket-to-SSH bridge βββββββββββββββββββββββββββββββββββββββ
|
| 63 |
log "[ubuntu] Starting WS-SSH bridge on 127.0.0.1:7862 ..."
|
| 64 |
python3 /opt/ws-ssh-bridge.py &
|
|
@@ -84,10 +95,11 @@ fi
|
|
| 84 |
# ββ Process summary βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
log "========================================"
|
| 86 |
log "[ubuntu] Services:"
|
| 87 |
-
log "[ubuntu] nginx PID=${NGINX_PID}
|
| 88 |
-
log "[ubuntu] sshd PID=${SSHD_PID}
|
| 89 |
-
log "[ubuntu]
|
| 90 |
-
log "[ubuntu]
|
|
|
|
| 91 |
log "========================================"
|
| 92 |
log "[ubuntu] Base packages: $(wc -l < /etc/base-packages.list 2>/dev/null || echo '?')"
|
| 93 |
log "[ubuntu] Current packages: $(dpkg-query -W -f='\n' 2>/dev/null | wc -l)"
|
|
@@ -96,7 +108,8 @@ log "[ubuntu] All processes:"
|
|
| 96 |
ps aux --no-headers 2>/dev/null | awk '{printf "[ubuntu] %-8s PID=%-6s %s\n", $1, $2, $11}' | while IFS= read -r line; do log "$line"; done
|
| 97 |
|
| 98 |
log "[ubuntu] ββ System ready ββ"
|
| 99 |
-
log "[ubuntu] View logs:
|
|
|
|
| 100 |
|
| 101 |
# ββ Heartbeat βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 102 |
(while true; do
|
|
|
|
| 59 |
log "[ubuntu] [FAILED] sshd failed to start"
|
| 60 |
fi
|
| 61 |
|
| 62 |
+
# ββ SSE log streamer ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
+
log "[ubuntu] Starting SSE log streamer on 127.0.0.1:7863 ..."
|
| 64 |
+
python3 /opt/log_streamer.py &
|
| 65 |
+
STREAMER_PID=$!
|
| 66 |
+
sleep 1
|
| 67 |
+
if kill -0 $STREAMER_PID 2>/dev/null; then
|
| 68 |
+
log "[ubuntu] [ OK ] SSE log streamer started (PID=${STREAMER_PID})"
|
| 69 |
+
else
|
| 70 |
+
log "[ubuntu] [FAILED] SSE log streamer failed to start"
|
| 71 |
+
fi
|
| 72 |
+
|
| 73 |
# ββ WebSocket-to-SSH bridge βββββββββββββββββββββββββββββββββββββββ
|
| 74 |
log "[ubuntu] Starting WS-SSH bridge on 127.0.0.1:7862 ..."
|
| 75 |
python3 /opt/ws-ssh-bridge.py &
|
|
|
|
| 95 |
# ββ Process summary βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
log "========================================"
|
| 97 |
log "[ubuntu] Services:"
|
| 98 |
+
log "[ubuntu] nginx PID=${NGINX_PID} 0.0.0.0:7860"
|
| 99 |
+
log "[ubuntu] sshd PID=${SSHD_PID} 127.0.0.1:${SSH_PORT}"
|
| 100 |
+
log "[ubuntu] log-streamer PID=${STREAMER_PID} 127.0.0.1:7863"
|
| 101 |
+
log "[ubuntu] ws-ssh-bridge PID=${BRIDGE_PID} 127.0.0.1:7862"
|
| 102 |
+
log "[ubuntu] ttyd PID=${TTYD_PID} 127.0.0.1:${TTYD_PORT}"
|
| 103 |
log "========================================"
|
| 104 |
log "[ubuntu] Base packages: $(wc -l < /etc/base-packages.list 2>/dev/null || echo '?')"
|
| 105 |
log "[ubuntu] Current packages: $(dpkg-query -W -f='\n' 2>/dev/null | wc -l)"
|
|
|
|
| 108 |
ps aux --no-headers 2>/dev/null | awk '{printf "[ubuntu] %-8s PID=%-6s %s\n", $1, $2, $11}' | while IFS= read -r line; do log "$line"; done
|
| 109 |
|
| 110 |
log "[ubuntu] ββ System ready ββ"
|
| 111 |
+
log "[ubuntu] View logs: curl https://<space>.hf.space/logs"
|
| 112 |
+
log "[ubuntu] Stream SSE: curl -N https://<space>.hf.space/logs/stream"
|
| 113 |
|
| 114 |
# ββ Heartbeat βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 115 |
(while true; do
|