main: deployment source of truth + homelab sidecar scripts - CI (image build + npm publish) now triggers on main instead of release. - homelab/: version-controlled copies of the NAS-side lazy proxies (whisper-lazy, runpod-lazy) and the runpod-pod CLI.

54b88d3c278f20af11bb25f34e328844ed810328

permissionBRICK <40219477+permissionBRICK@users.noreply.github.com>

5 files changed, +580 -2Ignore whitespace
.gitlab-ci.yml+2 -2
@@ -5,7 +5,7 @@ stages:
5build-image:5build-image:
6 stage: build6 stage: build
7 only:7 only:
8 - release8 - main
9 image: docker:latest9 image: docker:latest
10 services:10 services:
11 - name: docker:dind11 - name: docker:dind
@@ -33,7 +33,7 @@ build-image:
33publish-npm:33publish-npm:
34 stage: publish34 stage: publish
35 only:35 only:
36 - release36 - main
37 image: node:2437 image: node:24
38 variables:38 variables:
39 npm_config_cache: .npm39 npm_config_cache: .npm
homelab/README.md+15 -0
@@ -0,0 +1,15 @@
1# Homelab sidecar services
2
3Version-controlled copies of the NAS-side scripts that support this
4SillyTavern deployment. The deployed copies live on the unraid NAS; when
5changing one, update both (scp to the NAS path + commit here).
6
7| File | Deployed at | Purpose |
8|---|---|---|
9| `whisper-lazy-proxy.py` | `/mnt/user/appdata/whisper-lazy/proxy.py` (container `whisper-lazy`, :10300) | Wyoming lazy proxy: answers HA availability checks from cache, starts `faster-whisper` on demand, stops it after 15 min idle (frees GPU VRAM). |
10| `runpod-lazy-proxy.py` | `/mnt/user/appdata/runpod-lazy/proxy.py` (container `runpod-lazy`, :8189) | ComfyUI-compatible lazy proxy for a volumeless RunPod GPU pod: wake-on-generate with model-group detection, `/lazy/status|warmup|shutdown|ping` control API for the SillyTavern warmup UI, NAS-side 15-min idle terminate. |
11| `runpod-pod.sh` | `/boot/config/runpod-pod.sh` | CLI for the runpod-lazy control API (`status|warmup|shutdown|ping|watch`). |
12
13Related: the RunPod worker image (ComfyUI + custom nodes + boot-time model
14downloader) is built from github.com/permissionBRICK/comfyui-runpod-worker
15(private repo, public GHCR package; mirrored on this GitGudLab).
homelab/runpod-lazy-proxy.py+325 -0
@@ -0,0 +1,325 @@
1#!/usr/bin/env python3
2"""ComfyUI lazy proxy for a volumeless RunPod on-demand pod.
3
4SillyTavern's fallback chain points at this proxy as a normal ComfyUI server;
5the pod downloads its models at boot (boot-models.py in the image), so no
6network volume or region lock is needed.
7
8Control API (CORS-enabled, used by the ST warmup UI and the CLI script):
9 GET /lazy/status -> {"state": "red"|"orange"|"green", ...}
10 POST /lazy/warmup -> start pod with MODELS=all (idempotent)
11 POST /lazy/shutdown -> terminate pod now
12 POST /lazy/ping -> keepalive (extends idle timer; never starts a pod)
13
14Wake semantics: a ComfyUI /prompt POST arriving with no pod starts one that
15downloads only the model group the workflow references (flux/qwen/all).
16The idle timer lives HERE (NAS side): IDLE_SECONDS after the last activity
17signal (comfy traffic or keepalive ping) the pod is terminated - regardless
18of whether any frontend still exists.
19
20stdlib only; runs on python:3.12-alpine.
21"""
22import json
23import os
24import threading
25import time
26import urllib.error
27import urllib.request
28from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29
30RUNPOD_KEY = os.environ['RUNPOD_KEY']
31LISTEN_PORT = int(os.environ.get('LISTEN_PORT', '8189'))
32IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
33DATACENTERS = [d for d in os.environ.get('DATACENTERS', '').split(',') if d] # empty = any
34CLOUD_TYPE = os.environ.get('CLOUD_TYPE', 'SECURE')
35IMAGE = os.environ.get('IMAGE', 'ghcr.io/permissionbrick/comfyui-runpod-worker:latest')
36GPU_TYPES = os.environ.get('GPU_TYPES', 'NVIDIA RTX 6000 Ada Generation,NVIDIA L40S,NVIDIA L40,NVIDIA RTX A6000,NVIDIA A40').split(',')
37POD_NAME = os.environ.get('POD_NAME', 'comfyui-lazy')
38START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '1500'))
39HF_TOKEN = os.environ.get('HF_TOKEN', '')
40COMFY_ARGS = os.environ.get('COMFY_ARGS', '--listen 0.0.0.0 --port 8188 --use-pytorch-cross-attention')
41CACHE_FILE = os.environ.get('CACHE_FILE', '/app/system_stats.cache')
42
43# Model-group detection: filename fragment (lowercased) -> group
44GROUP_HINTS = {'flux-2-klein': 'flux', 'flux2-klein': 'flux', 'flux2-vae': 'flux', 'qwen-rapid': 'qwen'}
45
46state = {
47 'pod_id': None, # current pod id (may still be booting)
48 'models': None, # MODELS value the pod was created with
49 'phase': 'red', # red | orange | green
50 'gpu': None,
51 'since': time.time(),
52 'last': time.monotonic(),
53 'lock': threading.Lock(),
54}
55
56
57def log(*args):
58 print(time.strftime('%H:%M:%S'), *args, flush=True)
59
60
61def api(method, path, body=None):
62 req = urllib.request.Request(
63 f'https://rest.runpod.io/v1{path}',
64 json.dumps(body).encode() if body is not None else None,
65 {'Authorization': f'Bearer {RUNPOD_KEY}', 'Content-Type': 'application/json'},
66 method=method)
67 with urllib.request.urlopen(req, timeout=60) as resp:
68 data = resp.read()
69 return json.loads(data) if data else {}
70
71
72def find_pod():
73 try:
74 for pod in api('GET', '/pods'):
75 if pod.get('name') == POD_NAME and pod.get('desiredStatus') == 'RUNNING':
76 return pod['id'], (pod.get('env') or {}).get('MODELS'), pod.get('machine', {}).get('gpuTypeId')
77 except Exception as err:
78 log('find_pod failed:', err)
79 return None, None, None
80
81
82def create_pod(models):
83 body = {
84 'name': POD_NAME,
85 'imageName': IMAGE,
86 'gpuTypeIds': GPU_TYPES,
87 'gpuCount': 1,
88 'cloudType': CLOUD_TYPE,
89 'containerDiskInGb': 80,
90 'ports': ['8188/http'],
91 'env': {'MODELS': models, 'HF_TOKEN': HF_TOKEN},
92 'dockerStartCmd': ['bash', '-c', f'python3 /boot-models.py && cd /comfyui && exec python main.py {COMFY_ARGS}'],
93 }
94 if DATACENTERS:
95 body['dataCenterIds'] = DATACENTERS
96 pod = api('POST', '/pods', body)
97 log(f"pod created: {pod['id']} MODELS={models} {pod.get('machine', {}).get('gpuTypeId')} ${pod.get('costPerHr')}/hr")
98 return pod['id'], pod.get('machine', {}).get('gpuTypeId')
99
100
101def pod_url(pod_id):
102 return f'https://{pod_id}-8188.proxy.runpod.net'
103
104
105def upstream_ready(pod_id):
106 try:
107 with urllib.request.urlopen(pod_url(pod_id) + '/system_stats', timeout=5) as resp:
108 if resp.status == 200:
109 data = resp.read()
110 with open(CACHE_FILE, 'wb') as f:
111 f.write(data)
112 return True
113 except Exception:
114 pass
115 return False
116
117
118def groups_cover(have, need):
119 if not have:
120 return False
121 have_set = set(have.split(','))
122 need_set = set(need.split(','))
123 return 'all' in have_set or need_set <= have_set
124
125
126def ensure_pod(models='all', wait=True):
127 """Ensures a pod covering the requested model groups; optionally waits for readiness."""
128 with state['lock']:
129 pod_id, have, gpu = (state['pod_id'], state['models'], state['gpu'])
130 if not pod_id:
131 pod_id, have, gpu = find_pod()
132 if pod_id and not groups_cover(have, models):
133 log(f'pod has MODELS={have}, need {models} - recreating with all')
134 try:
135 api('DELETE', f'/pods/{pod_id}')
136 except Exception as err:
137 log('delete failed:', err)
138 pod_id, have, gpu = None, None, None
139 models = 'all'
140 if not pod_id:
141 state['phase'] = 'orange'
142 state['since'] = time.time()
143 last_err = None
144 for attempt in range(3):
145 try:
146 pod_id, gpu = create_pod(models)
147 break
148 except urllib.error.HTTPError as err:
149 last_err = err.read().decode()[:200]
150 log(f'create_pod attempt {attempt + 1} failed:', last_err)
151 time.sleep(5)
152 if not pod_id:
153 state['phase'] = 'red'
154 raise RuntimeError(f'could not create pod: {last_err}')
155 have = models
156 state['pod_id'], state['models'], state['gpu'] = pod_id, have, gpu
157 state['last'] = time.monotonic()
158
159 if state['phase'] != 'green':
160 state['phase'] = 'orange'
161 if not wait:
162 return pod_id
163 deadline = time.monotonic() + START_TIMEOUT
164 while time.monotonic() < deadline:
165 if upstream_ready(pod_id):
166 if state['phase'] != 'green':
167 state['phase'] = 'green'
168 state['since'] = time.time()
169 log('pod ready:', pod_id)
170 state['last'] = time.monotonic()
171 return pod_id
172 if state['pod_id'] != pod_id:
173 raise RuntimeError('pod replaced while waiting')
174 time.sleep(5)
175 raise RuntimeError('pod not ready within START_TIMEOUT')
176
177
178def detect_groups(workflow_bytes):
179 try:
180 text = workflow_bytes.decode('utf-8', 'replace').lower()
181 except Exception:
182 return 'all'
183 found = {group for hint, group in GROUP_HINTS.items() if hint in text}
184 if len(found) == 1:
185 return found.pop()
186 return 'all'
187
188
189def idle_reaper():
190 while True:
191 time.sleep(30)
192 idle = time.monotonic() - state['last']
193 if idle > IDLE_SECONDS:
194 pod_id = state['pod_id'] or find_pod()[0]
195 if pod_id:
196 log(f'idle {int(idle)}s - terminating pod')
197 try:
198 api('DELETE', f'/pods/{pod_id}')
199 except Exception as err:
200 log('terminate failed:', err)
201 if pod_id or state['phase'] != 'red':
202 state.update({'pod_id': None, 'models': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
203
204
205def status_body():
206 # Refresh green-ness cheaply on demand
207 pod_id = state['pod_id']
208 if pod_id and state['phase'] != 'green' and upstream_ready(pod_id):
209 state['phase'] = 'green'
210 state['since'] = time.time()
211 if not pod_id:
212 found, have, gpu = find_pod()
213 if found:
214 state.update({'pod_id': found, 'models': have, 'gpu': gpu})
215 state['phase'] = 'green' if upstream_ready(found) else 'orange'
216 else:
217 state['phase'] = 'red'
218 return json.dumps({
219 'state': state['phase'],
220 'pod_id': state['pod_id'],
221 'models': state['models'],
222 'gpu': state['gpu'],
223 'since': state['since'],
224 'url': pod_url(state['pod_id']) if state['pod_id'] else None,
225 'idle_seconds_left': max(0, IDLE_SECONDS - int(time.monotonic() - state['last'])) if state['pod_id'] else 0,
226 }).encode()
227
228
229class Proxy(BaseHTTPRequestHandler):
230 protocol_version = 'HTTP/1.1'
231
232 def log_message(self, fmt, *args):
233 pass
234
235 def _reply(self, code, data, ctype='application/json'):
236 self.send_response(code)
237 self.send_header('Content-Type', ctype or 'application/json')
238 self.send_header('Content-Length', str(len(data)))
239 self.send_header('Access-Control-Allow-Origin', '*')
240 self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
241 self.send_header('Access-Control-Allow-Headers', 'Content-Type')
242 self.end_headers()
243 self.wfile.write(data)
244
245 def do_OPTIONS(self):
246 self._reply(204, b'')
247
248 def _handle(self):
249 length = int(self.headers.get('Content-Length') or 0)
250 body = self.rfile.read(length) if length else None
251 path = self.path.split('?')[0]
252
253 # ---- control API ----
254 if path == '/lazy/status':
255 return self._reply(200, status_body())
256 if path == '/lazy/ping':
257 if state['pod_id']:
258 state['last'] = time.monotonic()
259 return self._reply(200, b'{"ok": true}')
260 if path == '/lazy/warmup':
261 threading.Thread(target=self._safe_ensure, args=('all',), daemon=True).start()
262 state['phase'] = state['phase'] if state['phase'] == 'green' else 'orange'
263 return self._reply(200, status_body())
264 if path == '/lazy/shutdown':
265 pod_id = state['pod_id'] or find_pod()[0]
266 if pod_id:
267 try:
268 api('DELETE', f'/pods/{pod_id}')
269 except Exception as err:
270 return self._reply(500, json.dumps({'error': str(err)}).encode())
271 state.update({'pod_id': None, 'models': None, 'gpu': None, 'phase': 'red', 'since': time.time()})
272 return self._reply(200, status_body())
273
274 # ---- comfy proxying ----
275 state['last'] = time.monotonic()
276 pod_id = state['pod_id']
277 ready = pod_id and upstream_ready(pod_id)
278 if not ready and self.command == 'GET' and path == '/system_stats':
279 # Reachability probe while cold: answer from cache; do NOT wake.
280 try:
281 with open(CACHE_FILE, 'rb') as f:
282 return self._reply(200, f.read())
283 except FileNotFoundError:
284 return self._reply(200, json.dumps({'system': {'comfyui_version': 'cold'}, 'devices': []}).encode())
285 if not ready:
286 models = detect_groups(body) if (self.command == 'POST' and path == '/prompt' and body) else 'all'
287 try:
288 pod_id = ensure_pod(models)
289 except Exception as err:
290 log('ensure_pod failed:', err)
291 return self._reply(502, json.dumps({'error': str(err)}).encode())
292 try:
293 req = urllib.request.Request(pod_url(pod_id) + self.path, body, method=self.command)
294 for header in ('Content-Type', 'Accept'):
295 if self.headers.get(header):
296 req.add_header(header, self.headers[header])
297 with urllib.request.urlopen(req, timeout=300) as resp:
298 self._reply(resp.status, resp.read(), resp.headers.get('Content-Type'))
299 except urllib.error.HTTPError as err:
300 self._reply(err.code, err.read())
301 except Exception as err:
302 log('upstream error:', err)
303 self._reply(502, json.dumps({'error': str(err)}).encode())
304 finally:
305 state['last'] = time.monotonic()
306
307 def _safe_ensure(self, models):
308 try:
309 ensure_pod(models)
310 except Exception as err:
311 log('warmup failed:', err)
312
313 do_GET = _handle
314 do_POST = _handle
315
316
317def main():
318 threading.Thread(target=idle_reaper, daemon=True).start()
319 server = ThreadingHTTPServer(('0.0.0.0', LISTEN_PORT), Proxy)
320 log(f'runpod-lazy v2 on :{LISTEN_PORT} (pod "{POD_NAME}", DCs {DATACENTERS or "any"}, idle {IDLE_SECONDS}s)')
321 server.serve_forever()
322
323
324if __name__ == '__main__':
325 main()
homelab/runpod-pod.sh+15 -0
@@ -0,0 +1,15 @@
1#!/bin/sh
2# CLI for the runpod-lazy proxy: status | warmup | shutdown | ping
3# Usable by anyone (or any AI) without SillyTavern. Proxy owns the pod
4# lifecycle; this only talks to its control API.
5CMD="${1:-status}"
6URL="${RUNPOD_LAZY_URL:-http://127.0.0.1:8189}"
7case "$CMD" in
8 status) curl -s "$URL/lazy/status" ;;
9 warmup) curl -s -X POST "$URL/lazy/warmup" ;;
10 shutdown) curl -s -X POST "$URL/lazy/shutdown" ;;
11 ping) curl -s -X POST "$URL/lazy/ping" ;;
12 watch) while true; do printf '%s ' "$(date +%H:%M:%S)"; curl -s "$URL/lazy/status"; echo; sleep 10; done ;;
13 *) echo "usage: runpod-pod.sh [status|warmup|shutdown|ping|watch]"; exit 1 ;;
14esac
15echo
homelab/whisper-lazy-proxy.py+223 -0
@@ -0,0 +1,223 @@
1#!/usr/bin/env python3
2"""Wyoming lazy proxy for faster-whisper.
3
4Home Assistant talks to this proxy instead of faster-whisper directly.
5- `describe` events are answered from a cached `info` response, so HA's
6 availability checks never touch the backend.
7- The first *real* event of a connection (e.g. `transcribe`) starts the
8 faster-whisper container via the docker socket, replays the event, and
9 relays bytes in both directions from then on.
10- After IDLE_SECONDS without an active session the container is stopped
11 again, freeing its VRAM.
12
13stdlib only; runs on any python:3.12 image.
14"""
15import asyncio
16import json
17import os
18import socket
19import time
20
21BACKEND_HOST = os.environ.get('BACKEND_HOST', '10.0.0.4')
22BACKEND_PORT = int(os.environ.get('BACKEND_PORT', '10302'))
23PROXY_PORT = int(os.environ.get('PROXY_PORT', '10300'))
24IDLE_SECONDS = int(os.environ.get('IDLE_SECONDS', '900'))
25CONTAINER = os.environ.get('CONTAINER', 'faster-whisper')
26DOCKER_SOCK = os.environ.get('DOCKER_SOCK', '/var/run/docker.sock')
27CACHE_FILE = os.environ.get('CACHE_FILE', '/app/info.cache')
28START_TIMEOUT = int(os.environ.get('START_TIMEOUT', '180'))
29
30state = {'active': 0, 'last': time.monotonic(), 'lock': None}
31
32
33def log(*args):
34 print(time.strftime('%H:%M:%S'), *args, flush=True)
35
36
37def docker_api(method, path):
38 """Minimal docker engine API call over the unix socket. Returns (status, body)."""
39 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
40 sock.settimeout(60)
41 sock.connect(DOCKER_SOCK)
42 sock.sendall(f'{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n'.encode())
43 buf = b''
44 while True:
45 chunk = sock.recv(65536)
46 if not chunk:
47 break
48 buf += chunk
49 sock.close()
50 head, _, body = buf.partition(b'\r\n\r\n')
51 return int(head.split(b' ', 2)[1]), body
52
53
54def backend_running():
55 try:
56 status, body = docker_api('GET', f'/containers/{CONTAINER}/json')
57 if status != 200:
58 return False
59 text = body.decode('utf-8', 'replace')
60 text = text[text.find('{'):text.rfind('}') + 1]
61 return json.loads(text).get('State', {}).get('Running', False)
62 except Exception as err:
63 log('backend_running check failed:', err)
64 return False
65
66
67def start_container():
68 status, _ = docker_api('POST', f'/containers/{CONTAINER}/start')
69 return status in (204, 304)
70
71
72def stop_container():
73 status, _ = docker_api('POST', f'/containers/{CONTAINER}/stop?t=30')
74 return status in (204, 304)
75
76
77async def read_event(reader):
78 """Reads one wyoming event; returns {'raw': bytes, 'type': str} or None on EOF."""
79 line = await reader.readline()
80 if not line:
81 return None
82 header = json.loads(line)
83 extra = b''
84 for key in ('data_length', 'payload_length'):
85 length = int(header.get(key) or 0)
86 if length:
87 extra += await reader.readexactly(length)
88 return {'raw': line + extra, 'type': header.get('type')}
89
90
91async def ensure_backend():
92 """Starts the backend container if needed and returns an open (reader, writer).
93
94 A bare TCP connect is not proof of readiness: docker-proxy accepts on the
95 published port as soon as the container starts, long before the Wyoming
96 server listens. So readiness = a real describe -> info handshake.
97 """
98 async with state['lock']:
99 deadline = time.monotonic() + START_TIMEOUT
100 if not await asyncio.to_thread(backend_running):
101 log('starting backend container')
102 await asyncio.to_thread(start_container)
103 while time.monotonic() < deadline:
104 try:
105 reader, writer = await asyncio.open_connection(BACKEND_HOST, BACKEND_PORT)
106 writer.write(json.dumps({'type': 'describe', 'data': {}}).encode() + b'\n')
107 await writer.drain()
108 event = await asyncio.wait_for(read_event(reader), 5)
109 if event and event['type'] == 'info':
110 # Connection is clean again after consuming the info event.
111 return reader, writer
112 writer.close()
113 except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, json.JSONDecodeError):
114 pass
115 await asyncio.sleep(1)
116 raise RuntimeError('backend did not come up within START_TIMEOUT')
117
118
119async def refresh_cache():
120 try:
121 reader, writer = await asyncio.open_connection(BACKEND_HOST, BACKEND_PORT)
122 writer.write(json.dumps({'type': 'describe', 'data': {}}).encode() + b'\n')
123 await writer.drain()
124 event = await asyncio.wait_for(read_event(reader), 20)
125 writer.close()
126 if event and event['type'] == 'info':
127 with open(CACHE_FILE, 'wb') as f:
128 f.write(event['raw'])
129 log('info cache refreshed:', len(event['raw']), 'bytes')
130 except Exception as err:
131 log('cache refresh failed:', err)
132
133
134async def pipe(reader, writer):
135 try:
136 while True:
137 chunk = await reader.read(65536)
138 if not chunk:
139 break
140 writer.write(chunk)
141 await writer.drain()
142 except (ConnectionResetError, BrokenPipeError):
143 pass
144 finally:
145 try:
146 writer.close()
147 except Exception:
148 pass
149
150
151async def handle_client(client_reader, client_writer):
152 peer = client_writer.get_extra_info('peername')
153 backend = None
154 try:
155 while backend is None:
156 event = await read_event(client_reader)
157 if event is None:
158 return
159 if event['type'] == 'ping':
160 client_writer.write(json.dumps({'type': 'pong', 'data': {}}).encode() + b'\n')
161 await client_writer.drain()
162 continue
163 if event['type'] == 'describe' and os.path.exists(CACHE_FILE):
164 with open(CACHE_FILE, 'rb') as f:
165 client_writer.write(f.read())
166 await client_writer.drain()
167 continue
168 # Real work (or describe without a cache yet): wake the backend.
169 state['active'] += 1
170 state['last'] = time.monotonic()
171 try:
172 backend = await ensure_backend()
173 except Exception as err:
174 state['active'] -= 1
175 state['last'] = time.monotonic()
176 log('backend wake failed:', err)
177 return
178 log(f'session start from {peer} (trigger: {event["type"]})')
179 backend[1].write(event['raw'])
180 await backend[1].drain()
181 asyncio.ensure_future(refresh_cache())
182 await asyncio.gather(pipe(client_reader, backend[1]), pipe(backend[0], client_writer))
183 except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError, json.JSONDecodeError) as err:
184 log('client error:', err)
185 finally:
186 if backend is not None:
187 state['active'] -= 1
188 state['last'] = time.monotonic()
189 log('session end, active:', state['active'])
190 try:
191 client_writer.close()
192 except Exception:
193 pass
194
195
196async def idle_reaper():
197 while True:
198 await asyncio.sleep(30)
199 idle = time.monotonic() - state['last']
200 if state['active'] == 0 and idle > IDLE_SECONDS and await asyncio.to_thread(backend_running):
201 log(f'idle for {int(idle)}s - stopping backend container')
202 await asyncio.to_thread(stop_container)
203
204
205async def main():
206 state['lock'] = asyncio.Lock()
207 if not os.path.exists(CACHE_FILE):
208 log('no info cache - bootstrapping backend once to capture it')
209 try:
210 reader, writer = await ensure_backend()
211 writer.close()
212 await refresh_cache()
213 except Exception as err:
214 log('bootstrap failed (will retry on first request):', err)
215 server = await asyncio.start_server(handle_client, '0.0.0.0', PROXY_PORT)
216 log(f'wyoming lazy proxy listening on :{PROXY_PORT} -> {BACKEND_HOST}:{BACKEND_PORT} '
217 f'(container {CONTAINER}, idle {IDLE_SECONDS}s)')
218 async with server:
219 await asyncio.gather(server.serve_forever(), idle_reaper())
220
221
222if __name__ == '__main__':
223 asyncio.run(main())