Tenoco commited on
Commit
ad29027
·
verified ·
1 Parent(s): a6efca4

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +995 -0
app.py ADDED
@@ -0,0 +1,995 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EchoNet server - a GitHub-backed "VPS" shell you connect to over WebSocket.
3
+
4
+ Env vars (set these in Render's dashboard):
5
+ GITHUB_TOKEN - a GitHub personal access token with repo access
6
+ GITHUB_REPO - e.g. "Tenoco/EchoNet"
7
+ GITHUB_BRANCH - defaults to "main"
8
+ SHELL_PASSWORD - the password echonetshell.py must send to connect
9
+ """
10
+
11
+ import os
12
+ import io
13
+ import json
14
+ import base64
15
+ import shutil
16
+ import shlex
17
+ import signal
18
+ import socket
19
+ import subprocess
20
+ import threading
21
+ import collections
22
+ import time
23
+ import uuid
24
+ import hashlib
25
+
26
+ import requests
27
+ from flask import Flask, request, Response
28
+ from flask_sock import Sock
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Config
32
+ # ---------------------------------------------------------------------------
33
+
34
+ GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
35
+ GITHUB_REPO = os.environ.get("GITHUB_REPO", "Tenoco/EchoNet")
36
+ GITHUB_BRANCH = os.environ.get("GITHUB_BRANCH", "main")
37
+ SHELL_PASSWORD = os.environ.get("SHELL_PASSWORD", "tenoco123!")
38
+
39
+ GH_API = f"https://api.github.com/repos/{GITHUB_REPO}/contents"
40
+ GH_HEADERS = {
41
+ "Authorization": f"token {GITHUB_TOKEN}",
42
+ "Accept": "application/vnd.github+json",
43
+ }
44
+
45
+ WORKDIR = "/tmp/echonet_apps"
46
+ os.makedirs(WORKDIR, exist_ok=True)
47
+
48
+ BASH_WORKDIR = os.path.join(WORKDIR, "_bash")
49
+ os.makedirs(BASH_WORKDIR, exist_ok=True)
50
+
51
+ RESERVED_ROUTES = {"shell", "", "healthz"}
52
+
53
+ GH_STATE_PATH = ".echonet_state.json"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # System stats (RAM, CPU vs Render's quota, GitHub repo size)
58
+ # ---------------------------------------------------------------------------
59
+
60
+ def get_ram_stats():
61
+ """Returns (total_mb, used_mb) scoped to this container, not the host.
62
+ /proc/meminfo reports the underlying host's physical memory (which on
63
+ Render's shared infra can be tens of GB) - the real 512MB-style limit
64
+ only shows up via the cgroup. We read that first and fall back to
65
+ /proc/meminfo only if cgroup info isn't available (e.g. running locally,
66
+ outside a container)."""
67
+ # cgroup v2
68
+ try:
69
+ with open("/sys/fs/cgroup/memory.max") as f:
70
+ max_raw = f.read().strip()
71
+ with open("/sys/fs/cgroup/memory.current") as f:
72
+ current = int(f.read().strip())
73
+ if max_raw != "max":
74
+ total = int(max_raw)
75
+ return round(total / 1024 / 1024, 1), round(current / 1024 / 1024, 1)
76
+ except Exception:
77
+ pass
78
+
79
+ # cgroup v1
80
+ try:
81
+ with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
82
+ total = int(f.read().strip())
83
+ with open("/sys/fs/cgroup/memory/memory.usage_in_bytes") as f:
84
+ current = int(f.read().strip())
85
+ if total < (1 << 62): # sentinel value for "unlimited" on some systems
86
+ return round(total / 1024 / 1024, 1), round(current / 1024 / 1024, 1)
87
+ except Exception:
88
+ pass
89
+
90
+ # fallback: host-level /proc/meminfo (only reached outside a container)
91
+ try:
92
+ info = {}
93
+ with open("/proc/meminfo") as f:
94
+ for line in f:
95
+ parts = line.split(":")
96
+ if len(parts) == 2:
97
+ info[parts[0].strip()] = int(parts[1].strip().split()[0]) # kB
98
+ total_kb = info.get("MemTotal", 0)
99
+ avail_kb = info.get("MemAvailable", info.get("MemFree", 0))
100
+ used_kb = max(total_kb - avail_kb, 0)
101
+ return round(total_kb / 1024, 1), round(used_kb / 1024, 1)
102
+ except Exception:
103
+ return None, None
104
+
105
+
106
+ def _read_cgroup_cpu_limit():
107
+ """Returns CPU limit in cores, e.g. 0.15, or None if not determinable."""
108
+ try:
109
+ # cgroup v2
110
+ with open("/sys/fs/cgroup/cpu.max") as f:
111
+ quota, period = f.read().split()
112
+ if quota == "max":
113
+ return None
114
+ return round(int(quota) / int(period), 3)
115
+ except Exception:
116
+ pass
117
+ try:
118
+ # cgroup v1
119
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f:
120
+ quota = int(f.read().strip())
121
+ with open("/sys/fs/cgroup/cpu/cpu.cfs_period_us") as f:
122
+ period = int(f.read().strip())
123
+ if quota <= 0:
124
+ return None
125
+ return round(quota / period, 3)
126
+ except Exception:
127
+ return None
128
+
129
+
130
+ def _read_cgroup_cpu_usage_usec():
131
+ try:
132
+ # cgroup v2
133
+ with open("/sys/fs/cgroup/cpu.stat") as f:
134
+ for line in f:
135
+ if line.startswith("usage_usec"):
136
+ return int(line.split()[1])
137
+ except Exception:
138
+ pass
139
+ try:
140
+ # cgroup v1 (nanoseconds)
141
+ with open("/sys/fs/cgroup/cpuacct/cpuacct.usage") as f:
142
+ return int(f.read().strip()) / 1000 # ns -> us
143
+ except Exception:
144
+ return None
145
+
146
+
147
+ def get_cpu_stats(sample_seconds=0.25):
148
+ """Returns (limit_cores_or_None, used_percent_of_limit_or_None)."""
149
+ limit = _read_cgroup_cpu_limit()
150
+ u0 = _read_cgroup_cpu_usage_usec()
151
+ if u0 is None:
152
+ # fall back to load average as a rough proxy
153
+ try:
154
+ load1, _, _ = os.getloadavg()
155
+ cores = os.cpu_count() or 1
156
+ return cores, round(min(load1 / cores, 1.0) * 100, 1)
157
+ except Exception:
158
+ return None, None
159
+ time.sleep(sample_seconds)
160
+ u1 = _read_cgroup_cpu_usage_usec()
161
+ if u1 is None or limit is None:
162
+ return limit, None
163
+ delta_cores_used = (u1 - u0) / 1_000_000 / sample_seconds
164
+ percent = round(min(delta_cores_used / limit, 1.0) * 100, 1) if limit else None
165
+ return limit, percent
166
+
167
+
168
+ def get_github_storage():
169
+ """Returns (used_mb, limit_description)."""
170
+ try:
171
+ r = requests.get(f"https://api.github.com/repos/{GITHUB_REPO}", headers=GH_HEADERS)
172
+ r.raise_for_status()
173
+ size_kb = r.json().get("size", 0)
174
+ return round(size_kb / 1024, 2), "no hard cap (~5GB soft limit)"
175
+ except Exception:
176
+ return None, "unknown"
177
+
178
+
179
+ def get_full_stats():
180
+ ram_total, ram_used = get_ram_stats()
181
+ cpu_limit, cpu_used_pct = get_cpu_stats()
182
+ gh_used, gh_limit = get_github_storage()
183
+ with HOSTED_LOCK:
184
+ apps_running = len(HOSTED)
185
+ return {
186
+ "ram_total_mb": ram_total,
187
+ "ram_used_mb": ram_used,
188
+ "cpu_limit_cores": cpu_limit,
189
+ "cpu_used_percent": cpu_used_pct,
190
+ "github_used_mb": gh_used,
191
+ "github_limit": gh_limit,
192
+ "apps_running": apps_running,
193
+ }
194
+
195
+ app = Flask(__name__)
196
+ sock = Sock(app)
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # GitHub-backed filesystem helpers
200
+ # ---------------------------------------------------------------------------
201
+
202
+ def gh_path(cwd, name=""):
203
+ parts = [p for p in (cwd + "/" + name).split("/") if p]
204
+ return "/".join(parts)
205
+
206
+
207
+ def resolve_user_path(cwd, raw):
208
+ """Resolve a path argument typed by the user. This 'filesystem' is just
209
+ a GitHub repo, so there's no real home directory - '~' and '~/x' are
210
+ treated as shorthand for the repo root, so paths like
211
+ '~/tenoco/tenocoservices/nexfund/nexfund.py' work no matter what
212
+ directory you're currently in."""
213
+ if raw is None:
214
+ return cwd
215
+ if raw == "~":
216
+ return ""
217
+ if raw.startswith("~/"):
218
+ return gh_path("", raw[2:])
219
+ return gh_path(cwd, raw)
220
+
221
+
222
+ def gh_list(path):
223
+ """List a directory. Returns list of {'name','type'} or raises."""
224
+ url = f"{GH_API}/{path}" if path else GH_API
225
+ r = requests.get(url, headers=GH_HEADERS, params={"ref": GITHUB_BRANCH})
226
+ if r.status_code == 404:
227
+ return None
228
+ r.raise_for_status()
229
+ data = r.json()
230
+ if isinstance(data, dict):
231
+ # it's a file, not a dir
232
+ return None
233
+ return [{"name": item["name"], "type": item["type"]} for item in data]
234
+
235
+
236
+ def gh_get_file(path):
237
+ """Returns (content_bytes, sha) or (None, None) if missing."""
238
+ url = f"{GH_API}/{path}"
239
+ r = requests.get(url, headers=GH_HEADERS, params={"ref": GITHUB_BRANCH})
240
+ if r.status_code == 404:
241
+ return None, None
242
+ r.raise_for_status()
243
+ data = r.json()
244
+ if not isinstance(data, dict):
245
+ # It's a directory listing, not a file - nothing to return.
246
+ return None, None
247
+ content = base64.b64decode(data["content"])
248
+ return content, data["sha"]
249
+
250
+
251
+ def gh_put_file(path, content_bytes, message):
252
+ _, sha = gh_get_file(path)
253
+ url = f"{GH_API}/{path}"
254
+ payload = {
255
+ "message": message,
256
+ "content": base64.b64encode(content_bytes).decode(),
257
+ "branch": GITHUB_BRANCH,
258
+ }
259
+ if sha:
260
+ payload["sha"] = sha
261
+ r = requests.put(url, headers=GH_HEADERS, json=payload)
262
+ r.raise_for_status()
263
+ return r.json()
264
+
265
+
266
+ def gh_delete_file(path, message):
267
+ _, sha = gh_get_file(path)
268
+ if not sha:
269
+ return False
270
+ url = f"{GH_API}/{path}"
271
+ r = requests.delete(
272
+ url,
273
+ headers=GH_HEADERS,
274
+ json={"message": message, "sha": sha, "branch": GITHUB_BRANCH},
275
+ )
276
+ # Handle 409 Conflict by refetching and retrying once
277
+ if r.status_code == 409:
278
+ _, sha = gh_get_file(path)
279
+ if not sha:
280
+ return False
281
+ r = requests.delete(
282
+ url,
283
+ headers=GH_HEADERS,
284
+ json={"message": message, "sha": sha, "branch": GITHUB_BRANCH},
285
+ )
286
+ r.raise_for_status()
287
+ return True
288
+
289
+
290
+ def gh_delete_dir(path, message):
291
+ entries = gh_list(path)
292
+ if entries is None:
293
+ return False
294
+ for e in entries:
295
+ sub = f"{path}/{e['name']}" if path else e["name"]
296
+ try:
297
+ if e["type"] == "dir":
298
+ gh_delete_dir(sub, message)
299
+ else:
300
+ gh_delete_file(sub, message)
301
+ except Exception:
302
+ # Continue deleting other files even if one fails
303
+ pass
304
+ return True
305
+
306
+
307
+ def gh_mkdir(path):
308
+ # GitHub has no real directories - create a .gitkeep placeholder
309
+ keep = f"{path}/.gitkeep" if path else ".gitkeep"
310
+ gh_put_file(keep, b"", f"mkdir {path}")
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Persisted "should be running" state - so hosted apps survive restarts
315
+ # ---------------------------------------------------------------------------
316
+
317
+ STATE_LOCK = threading.Lock()
318
+
319
+
320
+ def load_hosted_state():
321
+ try:
322
+ content, _ = gh_get_file(GH_STATE_PATH)
323
+ if content is None:
324
+ return {"hosted": []}
325
+ return json.loads(content)
326
+ except Exception:
327
+ return {"hosted": []}
328
+
329
+
330
+ def save_hosted_state(entries):
331
+ with STATE_LOCK:
332
+ gh_put_file(
333
+ GH_STATE_PATH,
334
+ json.dumps({"hosted": entries}, indent=2).encode(),
335
+ "update hosted app state",
336
+ )
337
+
338
+
339
+ def remember_hosted(name, cwd_path):
340
+ state = load_hosted_state()
341
+ entries = [e for e in state.get("hosted", []) if e.get("name") != name]
342
+ entries.append({"name": name, "cwd": cwd_path})
343
+ save_hosted_state(entries)
344
+
345
+
346
+ def forget_hosted(name):
347
+ state = load_hosted_state()
348
+ entries = [e for e in state.get("hosted", []) if e.get("name") != name]
349
+ save_hosted_state(entries)
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # Process manager (host / stop / logs)
354
+ # ---------------------------------------------------------------------------
355
+
356
+ class HostedApp:
357
+ def __init__(self, name, cwd_path, port, local_dir=None):
358
+ self.name = name
359
+ self.cwd_path = cwd_path
360
+ self.port = port
361
+ self.local_dir = local_dir
362
+ self.proc = None
363
+ self.log = collections.deque(maxlen=500)
364
+ self.started_at = time.time()
365
+ # rel_path -> sha1 of last-synced content, so periodic sync only
366
+ # pushes files that actually changed since the previous pass
367
+ self.synced_hashes = {}
368
+
369
+ def pump_output(self):
370
+ for line in iter(self.proc.stdout.readline, b""):
371
+ try:
372
+ self.log.append(line.decode(errors="replace").rstrip())
373
+ except Exception:
374
+ pass
375
+
376
+
377
+ HOSTED = {} # name -> HostedApp
378
+ HOSTED_LOCK = threading.Lock()
379
+
380
+ BOOT_ID = uuid.uuid4().hex[:8]
381
+ BOOT_EVENTS = []
382
+ BOOT_EVENTS_LOCK = threading.Lock()
383
+
384
+
385
+ def log_boot_event(msg):
386
+ with BOOT_EVENTS_LOCK:
387
+ BOOT_EVENTS.append(msg)
388
+
389
+
390
+ def free_port():
391
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
392
+ s.bind(("", 0))
393
+ port = s.getsockname()[1]
394
+ s.close()
395
+ return port
396
+
397
+
398
+ def sync_dir_to_local(cwd_path, local_dir):
399
+ """Pull an entire GitHub directory tree (files + subdirectories) down to
400
+ a local temp dir, so imports / sibling files (requirements.txt, static/,
401
+ templates/, instance/ etc) all work exactly like they would on a normal
402
+ filesystem."""
403
+ if os.path.exists(local_dir):
404
+ shutil.rmtree(local_dir)
405
+ os.makedirs(local_dir, exist_ok=True)
406
+ _sync_dir_to_local_recursive(cwd_path, local_dir)
407
+
408
+
409
+ def _sync_dir_to_local_recursive(gh_dir, local_target_dir):
410
+ entries = gh_list(gh_dir) or []
411
+ os.makedirs(local_target_dir, exist_ok=True)
412
+ for e in entries:
413
+ if e["name"] == ".gitkeep":
414
+ continue
415
+ sub = f"{gh_dir}/{e['name']}" if gh_dir else e["name"]
416
+ if e["type"] == "dir":
417
+ _sync_dir_to_local_recursive(sub, os.path.join(local_target_dir, e["name"]))
418
+ continue
419
+ content, _ = gh_get_file(sub)
420
+ if content is not None:
421
+ with open(os.path.join(local_target_dir, e["name"]), "wb") as f:
422
+ f.write(content)
423
+
424
+
425
+ SYNC_INTERVAL = 60 # seconds between periodic syncs of hosted-app dirs back to GitHub
426
+ SYNC_EXCLUDE_DIRS = {"__pycache__", ".git", "venv", ".venv", "node_modules"}
427
+ SYNC_EXCLUDE_EXT = {".pyc"}
428
+
429
+
430
+ def _hash_file(path):
431
+ with open(path, "rb") as f:
432
+ return hashlib.sha1(f.read()).hexdigest()
433
+
434
+
435
+ def _snapshot_local_hashes(local_dir):
436
+ """Hash every file already in local_dir right after it's pulled down
437
+ from GitHub, so the first periodic sync pass doesn't re-push files
438
+ that haven't actually changed."""
439
+ hashes = {}
440
+ for root, dirs, files in os.walk(local_dir):
441
+ dirs[:] = [d for d in dirs if d not in SYNC_EXCLUDE_DIRS]
442
+ for fname in files:
443
+ if os.path.splitext(fname)[1] in SYNC_EXCLUDE_EXT:
444
+ continue
445
+ full_path = os.path.join(root, fname)
446
+ rel_path = os.path.relpath(full_path, local_dir).replace(os.sep, "/")
447
+ try:
448
+ hashes[rel_path] = _hash_file(full_path)
449
+ except Exception:
450
+ pass
451
+ return hashes
452
+
453
+
454
+ def sync_hosted_app_to_github(hosted):
455
+ """Push any new/changed files in a hosted app's local working dir back
456
+ up to GitHub, transparent to the app itself. This is what makes local
457
+ writes (SQLite databases, logs, anything the app saves to disk) survive
458
+ a restart or redeploy - EchoNet syncs the directory both ways, not just
459
+ down at start time."""
460
+ local_dir = hosted.local_dir
461
+ if not local_dir or not os.path.isdir(local_dir):
462
+ return
463
+ for root, dirs, files in os.walk(local_dir):
464
+ dirs[:] = [d for d in dirs if d not in SYNC_EXCLUDE_DIRS]
465
+ for fname in files:
466
+ if os.path.splitext(fname)[1] in SYNC_EXCLUDE_EXT:
467
+ continue
468
+ full_path = os.path.join(root, fname)
469
+ rel_path = os.path.relpath(full_path, local_dir).replace(os.sep, "/")
470
+ try:
471
+ digest = _hash_file(full_path)
472
+ except Exception:
473
+ continue
474
+ if hosted.synced_hashes.get(rel_path) == digest:
475
+ continue # unchanged since last sync pass
476
+ gh_target = gh_path(hosted.cwd_path, rel_path)
477
+ try:
478
+ with open(full_path, "rb") as f:
479
+ content = f.read()
480
+ gh_put_file(gh_target, content, f"auto-sync {rel_path} from {hosted.name}")
481
+ hosted.synced_hashes[rel_path] = digest
482
+ except Exception as e:
483
+ hosted.log.append(f"[echonet-sync] failed to sync {rel_path}: {e}")
484
+
485
+
486
+ def periodic_sync_loop():
487
+ """Runs for the life of the server, pushing every hosted app's local
488
+ changes back to GitHub every SYNC_INTERVAL seconds."""
489
+ while True:
490
+ time.sleep(SYNC_INTERVAL)
491
+ with HOSTED_LOCK:
492
+ apps = list(HOSTED.values())
493
+ for hosted in apps:
494
+ try:
495
+ sync_hosted_app_to_github(hosted)
496
+ except Exception as e:
497
+ hosted.log.append(f"[echonet-sync] error: {e}")
498
+
499
+
500
+ def start_hosted_app(name, cwd_path, persist=True):
501
+ with HOSTED_LOCK:
502
+ if name in HOSTED:
503
+ return f"{name} is already hosted. Use 'stop {name}' first."
504
+
505
+ local_dir = os.path.join(WORKDIR, name.replace("/", "_").replace(".", "_"))
506
+ sync_dir_to_local(cwd_path, local_dir)
507
+
508
+ filepath = os.path.join(local_dir, os.path.basename(name))
509
+ if not os.path.exists(filepath):
510
+ return f"{name}: not found in current directory."
511
+
512
+ port = free_port()
513
+ env = os.environ.copy()
514
+ env["PORT"] = str(port)
515
+ env["HOST"] = "127.0.0.1"
516
+
517
+ # optional: install requirements.txt if present, best-effort, no output noise
518
+ req = os.path.join(local_dir, "requirements.txt")
519
+ if os.path.exists(req):
520
+ subprocess.run(
521
+ ["pip", "install", "--quiet", "--break-system-packages", "-r", req],
522
+ cwd=local_dir,
523
+ )
524
+
525
+ proc = subprocess.Popen(
526
+ ["python3", os.path.basename(name)],
527
+ cwd=local_dir,
528
+ env=env,
529
+ stdout=subprocess.PIPE,
530
+ stderr=subprocess.STDOUT,
531
+ )
532
+
533
+ hosted = HostedApp(name, cwd_path, port, local_dir=local_dir)
534
+ hosted.proc = proc
535
+ hosted.synced_hashes = _snapshot_local_hashes(local_dir)
536
+ with HOSTED_LOCK:
537
+ HOSTED[name] = hosted
538
+
539
+ t = threading.Thread(target=hosted.pump_output, daemon=True)
540
+ t.start()
541
+
542
+ if persist:
543
+ try:
544
+ remember_hosted(name, cwd_path)
545
+ except Exception:
546
+ pass
547
+
548
+ slug = os.path.splitext(os.path.basename(name))[0]
549
+ try:
550
+ base_url = request.url_root.rstrip("/")
551
+ except RuntimeError:
552
+ base_url = os.environ.get("RENDER_EXTERNAL_URL", "").rstrip("/")
553
+ return f"Hosted {name} on internal port {port}. URL: {base_url}/{slug}"
554
+
555
+
556
+ def kill_by_name(name):
557
+ """Best-effort: kill any process (tracked or orphaned) whose command
558
+ line references this filename. Needed because a prior server restart
559
+ can leave a subprocess running with no record of it in HOSTED."""
560
+ try:
561
+ result = subprocess.run(["pkill", "-f", os.path.basename(name)], timeout=5)
562
+ return result.returncode == 0
563
+ except Exception:
564
+ return False
565
+
566
+
567
+ def stop_hosted_app(name):
568
+ with HOSTED_LOCK:
569
+ hosted = HOSTED.pop(name, None)
570
+
571
+ killed_tracked = False
572
+ if hosted:
573
+ try:
574
+ sync_hosted_app_to_github(hosted)
575
+ except Exception:
576
+ pass
577
+ try:
578
+ hosted.proc.send_signal(signal.SIGTERM)
579
+ hosted.proc.wait(timeout=5)
580
+ except Exception:
581
+ try:
582
+ hosted.proc.kill()
583
+ except Exception:
584
+ pass
585
+ killed_tracked = True
586
+
587
+ orphan_killed = kill_by_name(name)
588
+
589
+ try:
590
+ forget_hosted(name)
591
+ except Exception:
592
+ pass
593
+
594
+ if killed_tracked:
595
+ return f"Stopped {name}."
596
+ elif orphan_killed:
597
+ return f"{name} wasn't tracked (likely orphaned from an earlier restart) - killed it by name anyway."
598
+ else:
599
+ return f"{name} is not running."
600
+
601
+
602
+ def stop_all_hosted():
603
+ with HOSTED_LOCK:
604
+ tracked_names = list(HOSTED.keys())
605
+
606
+ for name in tracked_names:
607
+ stop_hosted_app(name)
608
+
609
+ # sweep any orphaned processes from persisted state that were never
610
+ # re-tracked (e.g. survived a restart with no in-memory record)
611
+ state = load_hosted_state()
612
+ for entry in state.get("hosted", []):
613
+ name = entry.get("name")
614
+ if name and name not in tracked_names:
615
+ kill_by_name(name)
616
+
617
+ with HOSTED_LOCK:
618
+ HOSTED.clear()
619
+ save_hosted_state([])
620
+
621
+ count = len(tracked_names)
622
+ return f"Stopped {count} tracked app(s), swept orphans, and cleared hosted state."
623
+
624
+
625
+ def logs_hosted_app(name, n=40):
626
+ hosted = HOSTED.get(name)
627
+ if not hosted:
628
+ return f"{name} is not running."
629
+ lines = list(hosted.log)[-n:]
630
+ return "\n".join(lines) if lines else "(no output yet)"
631
+
632
+
633
+ def run_bash_command(shell_cmd, timeout=60):
634
+ """Run an arbitrary shell command/script directly on the server. This
635
+ runs in a local scratch dir (BASH_WORKDIR) - it is NOT the GitHub-backed
636
+ 'filesystem', so nothing here persists across restarts. Use it for
637
+ quick utility stuff (pip installs, curl checks, one-off scripts); use
638
+ ls/cd/nano/hostapp for anything that should live in the repo."""
639
+ if not shell_cmd.strip():
640
+ return "usage: bash <command>"
641
+ try:
642
+ result = subprocess.run(
643
+ shell_cmd,
644
+ shell=True,
645
+ cwd=BASH_WORKDIR,
646
+ capture_output=True,
647
+ timeout=timeout,
648
+ text=True,
649
+ )
650
+ out = ((result.stdout or "") + (result.stderr or "")).strip()
651
+ if not out:
652
+ out = f"(exit {result.returncode}, no output)"
653
+ return out
654
+ except subprocess.TimeoutExpired:
655
+ return f"bash: command timed out after {timeout}s"
656
+ except Exception as e:
657
+ return f"bash: {e}"
658
+
659
+
660
+ def list_hosted():
661
+ with HOSTED_LOCK:
662
+ if not HOSTED:
663
+ return "No apps currently hosted."
664
+ rows = []
665
+ for name, h in HOSTED.items():
666
+ slug = os.path.splitext(os.path.basename(name))[0]
667
+ alive = h.proc.poll() is None
668
+ rows.append(f"{name:20s} /{slug:15s} port={h.port:<6d} {'running' if alive else 'dead'}")
669
+ return "\n".join(rows)
670
+
671
+
672
+ # ---------------------------------------------------------------------------
673
+ # Reverse proxy for hosted apps: /<slug>/<subpath>
674
+ # ---------------------------------------------------------------------------
675
+
676
+ @app.route("/<slug>", defaults={"subpath": ""}, methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], strict_slashes=False)
677
+ @app.route("/<slug>/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
678
+ def proxy(slug, subpath):
679
+ if slug in RESERVED_ROUTES:
680
+ return "Not found", 404
681
+ # Answer CORS preflight immediately — forwarding it to the hosted app is
682
+ # unnecessary and was previously impossible anyway since OPTIONS wasn't
683
+ # even in the allowed methods above, causing every preflighted cross-
684
+ # origin POST (e.g. from the web app) to fail before it got here.
685
+ if request.method == "OPTIONS":
686
+ resp = Response("", 204)
687
+ resp.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin", "*")
688
+ resp.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, PATCH, OPTIONS"
689
+ resp.headers["Access-Control-Allow-Headers"] = request.headers.get("Access-Control-Request-Headers", "Content-Type, Authorization")
690
+ resp.headers["Access-Control-Max-Age"] = "86400"
691
+ return resp
692
+
693
+ target = None
694
+ with HOSTED_LOCK:
695
+ for name, h in HOSTED.items():
696
+ if os.path.splitext(os.path.basename(name))[0] == slug:
697
+ target = h
698
+ break
699
+ if not target:
700
+ return f"No app hosted at /{slug}", 404
701
+
702
+ url = f"http://127.0.0.1:{target.port}/{subpath}"
703
+ forward_headers = {k: v for k, v in request.headers if k.lower() != "host"}
704
+ forward_headers["X-Script-Name"] = f"/{slug}"
705
+ try:
706
+ resp = requests.request(
707
+ method=request.method,
708
+ url=url,
709
+ headers=forward_headers,
710
+ data=request.get_data(),
711
+ params=request.args,
712
+ cookies=request.cookies,
713
+ timeout=30,
714
+ allow_redirects=False,
715
+ )
716
+ except requests.exceptions.ConnectionError:
717
+ return f"{slug} is not responding yet (still starting up?)", 502
718
+
719
+ excluded = {"content-encoding", "content-length", "transfer-encoding", "connection"}
720
+ headers = []
721
+ for k, v in resp.raw.headers.items():
722
+ if k.lower() in excluded:
723
+ continue
724
+ if k.lower() == "location" and v.startswith("/") and not v.startswith(f"/{slug}"):
725
+ v = f"/{slug}{v}"
726
+ headers.append((k, v))
727
+ return Response(resp.content, resp.status_code, headers)
728
+
729
+
730
+ @app.route("/healthz")
731
+ def healthz():
732
+ return "ok"
733
+
734
+
735
+ # ---------------------------------------------------------------------------
736
+ # Shell command dispatcher
737
+ # ---------------------------------------------------------------------------
738
+
739
+ class Session:
740
+ def __init__(self):
741
+ self.cwd = "" # path relative to repo root, "" = root
742
+ self.pending_nano = None # filename waiting for __write__
743
+
744
+
745
+ def handle_command(sess, cmdline):
746
+ cmdline = cmdline.strip()
747
+ if not cmdline:
748
+ return ""
749
+
750
+ # __write__ carries raw file content (arbitrary code, arbitrary quotes) -
751
+ # it must never go through shlex, which chokes on unbalanced quotes in
752
+ # real-world source files. Handle it before any parsing happens.
753
+ if cmdline.startswith("__write__ "):
754
+ rest = cmdline[len("__write__ "):]
755
+ if " " not in rest:
756
+ return "error: usage __write__ <filename> <content>"
757
+ fname, content = rest.split(" ", 1)
758
+ target = gh_path(sess.cwd, fname)
759
+ try:
760
+ gh_put_file(target, content.encode(), f"edit {fname} via echonetshell")
761
+ return f"saved {fname}"
762
+ except Exception as e:
763
+ return f"error: {e}"
764
+
765
+ if cmdline.startswith("__read__ "):
766
+ fname = cmdline[len("__read__ "):].strip()
767
+ target = gh_path(sess.cwd, fname)
768
+ try:
769
+ content, sha = gh_get_file(target)
770
+ if content is None:
771
+ return f"error: {fname} not found"
772
+ return json.dumps({"content_b64": base64.b64encode(content).decode()})
773
+ except Exception as e:
774
+ return f"error: {e}"
775
+
776
+ if cmdline.startswith("bash "):
777
+ return run_bash_command(cmdline[len("bash "):])
778
+
779
+ try:
780
+ parts = shlex.split(cmdline)
781
+ except ValueError as e:
782
+ return f"error: {e}"
783
+ cmd, args = parts[0], parts[1:]
784
+
785
+ try:
786
+ if cmd == "pwd":
787
+ return "/" + sess.cwd
788
+
789
+ if cmd == "ls":
790
+ target = resolve_user_path(sess.cwd, args[0]) if args else sess.cwd
791
+ entries = gh_list(target)
792
+ if entries is None:
793
+ return f"ls: cannot access '{args[0] if args else '.'}': No such directory"
794
+ items = sorted(
795
+ (e for e in entries if e["name"] != ".gitkeep"),
796
+ key=lambda e: e["name"].lower(),
797
+ )
798
+ return json.dumps({"type": "ls", "entries": items})
799
+
800
+ if cmd == "cd":
801
+ if not args or args[0] in ("/", "~"):
802
+ sess.cwd = ""
803
+ return ""
804
+ if args[0] == "..":
805
+ sess.cwd = "/".join(sess.cwd.split("/")[:-1]) if sess.cwd else ""
806
+ return ""
807
+ target = resolve_user_path(sess.cwd, args[0])
808
+ entries = gh_list(target)
809
+ if entries is None:
810
+ return f"cd: no such directory: {args[0]}"
811
+ sess.cwd = target
812
+ return ""
813
+
814
+ if cmd == "mkdir":
815
+ if not args:
816
+ return "usage: mkdir <name>"
817
+ gh_mkdir(resolve_user_path(sess.cwd, args[0]))
818
+ return f"created {args[0]}"
819
+
820
+ if cmd == "rm":
821
+ if not args:
822
+ return "usage: rm <name>"
823
+ target = resolve_user_path(sess.cwd, args[0])
824
+ content, sha = gh_get_file(target)
825
+ if sha:
826
+ gh_delete_file(target, f"rm {args[0]}")
827
+ return f"removed {args[0]}"
828
+ if gh_delete_dir(target, f"rm -r {args[0]}"):
829
+ return f"removed directory {args[0]}"
830
+ return f"rm: no such file or directory: {args[0]}"
831
+
832
+ if cmd == "nano":
833
+ if not args:
834
+ return "usage: nano <filename>"
835
+ fname = args[0]
836
+ target = resolve_user_path(sess.cwd, fname)
837
+ content, _ = gh_get_file(target)
838
+ sess.pending_nano = fname
839
+ return json.dumps({
840
+ "type": "nano",
841
+ "filename": fname,
842
+ "content": content.decode(errors="replace") if content else "",
843
+ })
844
+
845
+ if cmd in ("hostapp", "host") and args and args[0] != "ls":
846
+ resolved = resolve_user_path(sess.cwd, args[0])
847
+ if "/" in resolved:
848
+ dir_part, file_part = resolved.rsplit("/", 1)
849
+ else:
850
+ dir_part, file_part = "", resolved
851
+ return start_hosted_app(file_part, dir_part)
852
+
853
+ if cmd == "host" and args and args[0] == "ls":
854
+ return list_hosted()
855
+
856
+ if cmd == "stop":
857
+ if not args:
858
+ return "usage: stop <name> | stop all"
859
+ if args[0] == "all":
860
+ return stop_all_hosted()
861
+ return stop_hosted_app(os.path.basename(args[0]))
862
+
863
+ if cmd == "logs":
864
+ if not args:
865
+ return "usage: logs <name>"
866
+ return logs_hosted_app(os.path.basename(args[0]))
867
+
868
+ if cmd == "stats":
869
+ return json.dumps({"type": "stats", "stats": get_full_stats()})
870
+
871
+ if cmd == "bash":
872
+ return "usage: bash <command>"
873
+
874
+ if cmd == "help":
875
+ return (
876
+ "ls, cd, mkdir, rm, nano, upload, download, hostapp/host <file>, "
877
+ "host ls, logs <name>, stop <name>, stop all, stats, pwd, bash <cmd>\n"
878
+ "paths accept '~/' as shorthand for the repo root, e.g.\n"
879
+ " hostapp ~/tenoco/tenocoservices/nexfund/nexfund.py\n"
880
+ "client-side only: launch, clear, exit"
881
+ )
882
+
883
+ return f"{cmd}: command not found"
884
+
885
+ except requests.exceptions.HTTPError as e:
886
+ return f"GitHub error: {e}"
887
+ except Exception as e:
888
+ return f"error: {e}"
889
+
890
+
891
+ # ---------------------------------------------------------------------------
892
+ # WebSocket shell endpoint
893
+ # ---------------------------------------------------------------------------
894
+
895
+ @sock.route("/shell")
896
+ def shell(ws):
897
+ # first message must be the password
898
+ raw = ws.receive()
899
+ try:
900
+ first = json.loads(raw)
901
+ except Exception:
902
+ ws.send(json.dumps({"type": "error", "message": "bad handshake"}))
903
+ return
904
+
905
+ if first.get("password") != SHELL_PASSWORD:
906
+ ws.send(json.dumps({"type": "error", "message": "wrong password"}))
907
+ return
908
+
909
+ ws.send(json.dumps({
910
+ "type": "auth_ok",
911
+ "message": "connected to EchoNet",
912
+ "boot_id": BOOT_ID,
913
+ "boot_events": BOOT_EVENTS,
914
+ "running": list_hosted(),
915
+ "stats": get_full_stats(),
916
+ }))
917
+
918
+ sess = Session()
919
+ while True:
920
+ raw = ws.receive()
921
+ if raw is None:
922
+ break
923
+ try:
924
+ msg = json.loads(raw)
925
+ except Exception:
926
+ continue
927
+
928
+ if "upload" in msg:
929
+ up = msg["upload"]
930
+ fname = up.get("filename", "")
931
+ b64 = up.get("content_b64", "")
932
+ try:
933
+ raw_bytes = base64.b64decode(b64)
934
+ target = gh_path(sess.cwd, fname)
935
+ gh_put_file(target, raw_bytes, f"upload {fname} via echonetshell")
936
+ output = f"uploaded {fname} ({len(raw_bytes)} bytes)"
937
+ except Exception as e:
938
+ output = f"upload error: {e}"
939
+ ws.send(json.dumps({"type": "output", "cwd": sess.cwd, "output": output}))
940
+ continue
941
+
942
+ cmd = msg.get("cmd", "")
943
+ output = handle_command(sess, cmd)
944
+ ws.send(json.dumps({"type": "output", "cwd": sess.cwd, "output": output}))
945
+
946
+
947
+ if __name__ == "__main__":
948
+ port = int(os.environ.get("PORT", 5000))
949
+ app.run(host="0.0.0.0", port=port)
950
+
951
+
952
+ # ---------------------------------------------------------------------------
953
+ # Startup tasks - run once when gunicorn (or `python server.py`) loads this
954
+ # module. With -w 1 this runs exactly once per boot.
955
+ # ---------------------------------------------------------------------------
956
+
957
+ def restore_hosted_apps():
958
+ """Re-host whatever was running before the last restart/spin-down."""
959
+ time.sleep(5) # let the server finish booting first
960
+ state = load_hosted_state()
961
+ entries = state.get("hosted", [])
962
+ if not entries:
963
+ log_boot_event("no previously hosted apps to restore")
964
+ return
965
+ for entry in entries:
966
+ name = entry.get("name")
967
+ cwd_path = entry.get("cwd", "")
968
+ if not name:
969
+ continue
970
+ try:
971
+ result = start_hosted_app(name, cwd_path, persist=False)
972
+ log_boot_event(f"restored {name}: {result}")
973
+ except Exception as e:
974
+ log_boot_event(f"failed to restore {name}: {e}")
975
+
976
+
977
+ def self_ping_loop():
978
+ """Ping our own public URL every 5 minutes to discourage Render's free
979
+ tier from spinning the instance down. Requires RENDER_EXTERNAL_URL,
980
+ which Render sets automatically for web services."""
981
+ self_url = os.environ.get("RENDER_EXTERNAL_URL") or os.environ.get("SELF_URL")
982
+ if not self_url:
983
+ return
984
+ health_url = f"{self_url.rstrip('/')}/healthz"
985
+ while True:
986
+ time.sleep(300)
987
+ try:
988
+ requests.get(health_url, timeout=15)
989
+ except Exception:
990
+ pass
991
+
992
+
993
+ threading.Thread(target=restore_hosted_apps, daemon=True).start()
994
+ threading.Thread(target=self_ping_loop, daemon=True).start()
995
+ threading.Thread(target=periodic_sync_loop, daemon=True).start()