rasa2 commited on
Commit
970a5a0
·
1 Parent(s): c3cc2ac

Replace app.py with complete Fury broker

Browse files
Files changed (1) hide show
  1. app.py +280 -26
app.py CHANGED
@@ -1,18 +1,30 @@
1
  import os
2
  import uuid
3
  from enum import Enum
 
4
  from typing import Optional
5
 
6
- from fastapi import FastAPI, Header, HTTPException, Form
7
  from fastapi.responses import HTMLResponse, RedirectResponse
8
  from pydantic import BaseModel
9
 
10
 
 
 
 
 
11
  BROKER_TOKEN = os.environ.get("BROKER_TOKEN")
12
 
13
  if not BROKER_TOKEN:
14
- raise RuntimeError("Missing BROKER_TOKEN environment variable")
 
 
 
 
15
 
 
 
 
16
 
17
  class JobStatus(str, Enum):
18
  queued = "queued"
@@ -28,69 +40,198 @@ class Job(BaseModel):
28
  result: Optional[str] = None
29
 
30
 
31
- app = FastAPI()
 
 
32
 
 
 
 
 
33
  jobs: dict[str, Job] = {}
34
 
35
 
 
 
 
36
  ALLOWED_COMMANDS = {
37
  "hostname": "hostname",
38
  "whoami": "whoami",
39
  "pwd": "pwd",
40
  "disk": "df -h",
41
  "date": "date",
 
42
  }
43
 
44
 
45
- def verify_token(authorization: Optional[str]) -> None:
46
- expected = f"Bearer {BROKER_TOKEN}"
47
- if authorization != expected:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  raise HTTPException(status_code=401, detail="Unauthorized")
49
 
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  @app.get("/", response_class=HTMLResponse)
52
  def home():
53
  rows = ""
54
 
55
  for job in reversed(list(jobs.values())):
56
- result = job.result or ""
 
 
 
 
57
  rows += f"""
58
  <tr>
59
- <td>{job.id}</td>
60
- <td>{job.command}</td>
61
- <td>{job.status}</td>
62
- <td><pre>{result}</pre></td>
63
  </tr>
64
  """
65
 
66
  options = "\n".join(
67
- f'<option value="{name}">{name} -> {cmd}</option>'
68
  for name, cmd in ALLOWED_COMMANDS.items()
69
  )
70
 
71
  return f"""
 
72
  <html>
73
  <head>
74
  <title>Fury Broker</title>
75
  <style>
76
- body {{ font-family: sans-serif; margin: 40px; }}
77
- table {{ border-collapse: collapse; width: 100%; }}
78
- th, td {{ border: 1px solid #ccc; padding: 8px; vertical-align: top; }}
79
- pre {{ white-space: pre-wrap; max-width: 700px; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  </style>
81
  </head>
82
  <body>
83
  <h1>Fury Broker</h1>
 
 
 
 
 
 
 
 
84
 
85
  <form method="post" action="/submit">
86
- <label>Command:</label>
87
- <select name="command">
88
  {options}
89
  </select>
90
  <button type="submit">Submit job</button>
91
  </form>
92
 
 
 
 
 
 
93
  <h2>Jobs</h2>
 
94
  <table>
95
  <tr>
96
  <th>ID</th>
@@ -107,10 +248,18 @@ def home():
107
 
108
  @app.post("/submit")
109
  def submit_from_ui(command: str = Form(...)):
 
 
 
 
 
 
 
110
  if command not in ALLOWED_COMMANDS:
111
  raise HTTPException(status_code=400, detail="Command is not allowed")
112
 
113
  job_id = str(uuid.uuid4())
 
114
  jobs[job_id] = Job(
115
  id=job_id,
116
  command=command,
@@ -120,33 +269,89 @@ def submit_from_ui(command: str = Form(...)):
120
  return RedirectResponse("/", status_code=303)
121
 
122
 
 
 
 
 
123
  @app.post("/api/jobs")
124
  def submit_job(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  command: str,
126
- authorization: Optional[str] = Header(default=None),
127
  ):
128
- verify_token(authorization)
129
 
130
  if command not in ALLOWED_COMMANDS:
131
  raise HTTPException(status_code=400, detail="Command is not allowed")
132
 
133
  job_id = str(uuid.uuid4())
 
134
  job = Job(
135
  id=job_id,
136
  command=command,
137
  status=JobStatus.queued,
138
  )
 
139
  jobs[job_id] = job
 
140
  return job
141
 
142
 
 
 
 
 
143
  @app.get("/api/next-job")
144
- def get_next_job(authorization: Optional[str] = Header(default=None)):
145
- verify_token(authorization)
 
 
 
 
 
 
 
 
146
 
147
  for job in jobs.values():
148
  if job.status == JobStatus.queued:
149
  job.status = JobStatus.running
 
150
  return {
151
  "id": job.id,
152
  "command": job.command,
@@ -156,14 +361,21 @@ def get_next_job(authorization: Optional[str] = Header(default=None)):
156
  return {"id": None}
157
 
158
 
 
 
 
 
159
  @app.post("/api/jobs/{job_id}/result")
160
  def post_result(
161
  job_id: str,
162
- result: str,
163
- success: bool,
164
- authorization: Optional[str] = Header(default=None),
165
  ):
166
- verify_token(authorization)
 
 
 
167
 
168
  if job_id not in jobs:
169
  raise HTTPException(status_code=404, detail="Job not found")
@@ -173,3 +385,45 @@ def post_result(
173
  job.status = JobStatus.done if success else JobStatus.failed
174
 
175
  return job
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import uuid
3
  from enum import Enum
4
+ from html import escape
5
  from typing import Optional
6
 
7
+ from fastapi import FastAPI, Form, Header, HTTPException
8
  from fastapi.responses import HTMLResponse, RedirectResponse
9
  from pydantic import BaseModel
10
 
11
 
12
+ # ---------------------------------------------------------------------
13
+ # Configuration
14
+ # ---------------------------------------------------------------------
15
+
16
  BROKER_TOKEN = os.environ.get("BROKER_TOKEN")
17
 
18
  if not BROKER_TOKEN:
19
+ raise RuntimeError(
20
+ "Missing BROKER_TOKEN. Add it in Hugging Face Space "
21
+ "Settings → Variables and secrets → New secret."
22
+ )
23
+
24
 
25
+ # ---------------------------------------------------------------------
26
+ # Data model
27
+ # ---------------------------------------------------------------------
28
 
29
  class JobStatus(str, Enum):
30
  queued = "queued"
 
40
  result: Optional[str] = None
41
 
42
 
43
+ # ---------------------------------------------------------------------
44
+ # FastAPI app
45
+ # ---------------------------------------------------------------------
46
 
47
+ app = FastAPI(title="Fury Broker")
48
+
49
+ # In-memory job storage.
50
+ # Note: jobs disappear if the Space restarts.
51
  jobs: dict[str, Job] = {}
52
 
53
 
54
+ # Only commands listed here can be executed by Fury.
55
+ # The browser/Space user submits the logical command name.
56
+ # The Fury worker receives the shell command.
57
  ALLOWED_COMMANDS = {
58
  "hostname": "hostname",
59
  "whoami": "whoami",
60
  "pwd": "pwd",
61
  "disk": "df -h",
62
  "date": "date",
63
+ "list_home": "ls -lah ~ | head -50",
64
  }
65
 
66
 
67
+ # ---------------------------------------------------------------------
68
+ # Security
69
+ # ---------------------------------------------------------------------
70
+
71
+ def verify_broker_token(x_broker_token: Optional[str]) -> None:
72
+ """
73
+ Broker-level authentication.
74
+
75
+ Important:
76
+ - Hugging Face private Spaces use:
77
+ Authorization: Bearer HF_TOKEN
78
+
79
+ - This app uses:
80
+ X-Broker-Token: BROKER_TOKEN
81
+
82
+ This avoids conflict between Hugging Face authentication and
83
+ the app's own authentication.
84
+ """
85
+ if x_broker_token != BROKER_TOKEN:
86
  raise HTTPException(status_code=401, detail="Unauthorized")
87
 
88
 
89
+ # ---------------------------------------------------------------------
90
+ # Health and diagnostics
91
+ # ---------------------------------------------------------------------
92
+
93
+ @app.get("/health")
94
+ def health():
95
+ return {
96
+ "status": "ok",
97
+ "service": "fury-broker",
98
+ "jobs_count": len(jobs),
99
+ }
100
+
101
+
102
+ @app.get("/api/commands")
103
+ def list_commands(x_broker_token: Optional[str] = Header(default=None)):
104
+ verify_broker_token(x_broker_token)
105
+
106
+ return {
107
+ "commands": [
108
+ {
109
+ "name": name,
110
+ "shell_command": shell_command,
111
+ }
112
+ for name, shell_command in ALLOWED_COMMANDS.items()
113
+ ]
114
+ }
115
+
116
+
117
+ # ---------------------------------------------------------------------
118
+ # Browser UI
119
+ # ---------------------------------------------------------------------
120
+
121
  @app.get("/", response_class=HTMLResponse)
122
  def home():
123
  rows = ""
124
 
125
  for job in reversed(list(jobs.values())):
126
+ safe_id = escape(job.id)
127
+ safe_command = escape(job.command)
128
+ safe_status = escape(job.status.value)
129
+ safe_result = escape(job.result or "")
130
+
131
  rows += f"""
132
  <tr>
133
+ <td><code>{safe_id}</code></td>
134
+ <td>{safe_command}</td>
135
+ <td>{safe_status}</td>
136
+ <td><pre>{safe_result}</pre></td>
137
  </tr>
138
  """
139
 
140
  options = "\n".join(
141
+ f'<option value="{escape(name)}">{escape(name)} {escape(cmd)}</option>'
142
  for name, cmd in ALLOWED_COMMANDS.items()
143
  )
144
 
145
  return f"""
146
+ <!doctype html>
147
  <html>
148
  <head>
149
  <title>Fury Broker</title>
150
  <style>
151
+ body {{
152
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
153
+ margin: 40px;
154
+ max-width: 1200px;
155
+ line-height: 1.45;
156
+ }}
157
+ h1 {{
158
+ margin-bottom: 0.2rem;
159
+ }}
160
+ .subtitle {{
161
+ color: #555;
162
+ margin-bottom: 2rem;
163
+ }}
164
+ form {{
165
+ margin: 1.5rem 0;
166
+ padding: 1rem;
167
+ border: 1px solid #ddd;
168
+ border-radius: 8px;
169
+ background: #fafafa;
170
+ }}
171
+ select {{
172
+ padding: 6px;
173
+ min-width: 260px;
174
+ }}
175
+ button {{
176
+ padding: 6px 12px;
177
+ cursor: pointer;
178
+ }}
179
+ table {{
180
+ border-collapse: collapse;
181
+ width: 100%;
182
+ margin-top: 20px;
183
+ }}
184
+ th, td {{
185
+ border: 1px solid #ccc;
186
+ padding: 8px;
187
+ vertical-align: top;
188
+ }}
189
+ th {{
190
+ background: #f5f5f5;
191
+ text-align: left;
192
+ }}
193
+ pre {{
194
+ white-space: pre-wrap;
195
+ max-width: 800px;
196
+ max-height: 400px;
197
+ overflow: auto;
198
+ margin: 0;
199
+ }}
200
+ code {{
201
+ font-size: 0.9em;
202
+ }}
203
+ .note {{
204
+ color: #666;
205
+ font-size: 0.95rem;
206
+ }}
207
  </style>
208
  </head>
209
  <body>
210
  <h1>Fury Broker</h1>
211
+ <div class="subtitle">
212
+ Hugging Face Space broker for running approved commands on Fury.
213
+ </div>
214
+
215
+ <p>
216
+ This Space does not SSH into Fury. Instead, the Fury worker polls this Space
217
+ for queued jobs, executes only predefined commands, and posts the result back.
218
+ </p>
219
 
220
  <form method="post" action="/submit">
221
+ <label for="command"><strong>Command:</strong></label>
222
+ <select id="command" name="command">
223
  {options}
224
  </select>
225
  <button type="submit">Submit job</button>
226
  </form>
227
 
228
+ <p class="note">
229
+ Refresh the page after submitting a job. The worker should pick it up within
230
+ a few seconds if it is running on Fury.
231
+ </p>
232
+
233
  <h2>Jobs</h2>
234
+
235
  <table>
236
  <tr>
237
  <th>ID</th>
 
248
 
249
  @app.post("/submit")
250
  def submit_from_ui(command: str = Form(...)):
251
+ """
252
+ Browser form endpoint.
253
+
254
+ This endpoint is intentionally not protected by X-Broker-Token because
255
+ the Space itself is private. If you later make the Space public, protect
256
+ this endpoint too.
257
+ """
258
  if command not in ALLOWED_COMMANDS:
259
  raise HTTPException(status_code=400, detail="Command is not allowed")
260
 
261
  job_id = str(uuid.uuid4())
262
+
263
  jobs[job_id] = Job(
264
  id=job_id,
265
  command=command,
 
269
  return RedirectResponse("/", status_code=303)
270
 
271
 
272
+ # ---------------------------------------------------------------------
273
+ # API: submit job
274
+ # ---------------------------------------------------------------------
275
+
276
  @app.post("/api/jobs")
277
  def submit_job(
278
+ command: str = Form(...),
279
+ x_broker_token: Optional[str] = Header(default=None),
280
+ ):
281
+ """
282
+ Submit a job via API.
283
+
284
+ Example:
285
+
286
+ curl -X POST \\
287
+ -H "Authorization: Bearer $HF_TOKEN" \\
288
+ -H "X-Broker-Token: $BROKER_TOKEN" \\
289
+ -F "command=hostname" \\
290
+ https://rasa2-fury.hf.space/api/jobs
291
+ """
292
+ verify_broker_token(x_broker_token)
293
+
294
+ if command not in ALLOWED_COMMANDS:
295
+ raise HTTPException(status_code=400, detail="Command is not allowed")
296
+
297
+ job_id = str(uuid.uuid4())
298
+
299
+ job = Job(
300
+ id=job_id,
301
+ command=command,
302
+ status=JobStatus.queued,
303
+ )
304
+
305
+ jobs[job_id] = job
306
+
307
+ return job
308
+
309
+
310
+ # Also allow query-param style:
311
+ # POST /api/jobs/query?command=hostname
312
+ @app.post("/api/jobs/query")
313
+ def submit_job_query(
314
  command: str,
315
+ x_broker_token: Optional[str] = Header(default=None),
316
  ):
317
+ verify_broker_token(x_broker_token)
318
 
319
  if command not in ALLOWED_COMMANDS:
320
  raise HTTPException(status_code=400, detail="Command is not allowed")
321
 
322
  job_id = str(uuid.uuid4())
323
+
324
  job = Job(
325
  id=job_id,
326
  command=command,
327
  status=JobStatus.queued,
328
  )
329
+
330
  jobs[job_id] = job
331
+
332
  return job
333
 
334
 
335
+ # ---------------------------------------------------------------------
336
+ # API: worker polling
337
+ # ---------------------------------------------------------------------
338
+
339
  @app.get("/api/next-job")
340
+ def get_next_job(x_broker_token: Optional[str] = Header(default=None)):
341
+ """
342
+ Called by the Fury worker.
343
+
344
+ The worker asks:
345
+ "Do you have a job for me?"
346
+
347
+ If a queued job exists, this endpoint marks it as running and returns it.
348
+ """
349
+ verify_broker_token(x_broker_token)
350
 
351
  for job in jobs.values():
352
  if job.status == JobStatus.queued:
353
  job.status = JobStatus.running
354
+
355
  return {
356
  "id": job.id,
357
  "command": job.command,
 
361
  return {"id": None}
362
 
363
 
364
+ # ---------------------------------------------------------------------
365
+ # API: worker posts result
366
+ # ---------------------------------------------------------------------
367
+
368
  @app.post("/api/jobs/{job_id}/result")
369
  def post_result(
370
  job_id: str,
371
+ result: str = Form(...),
372
+ success: bool = Form(...),
373
+ x_broker_token: Optional[str] = Header(default=None),
374
  ):
375
+ """
376
+ Called by the Fury worker after it executes a command.
377
+ """
378
+ verify_broker_token(x_broker_token)
379
 
380
  if job_id not in jobs:
381
  raise HTTPException(status_code=404, detail="Job not found")
 
385
  job.status = JobStatus.done if success else JobStatus.failed
386
 
387
  return job
388
+
389
+
390
+ # ---------------------------------------------------------------------
391
+ # API: inspect jobs
392
+ # ---------------------------------------------------------------------
393
+
394
+ @app.get("/api/jobs")
395
+ def list_jobs(x_broker_token: Optional[str] = Header(default=None)):
396
+ verify_broker_token(x_broker_token)
397
+
398
+ return {
399
+ "jobs": list(jobs.values())
400
+ }
401
+
402
+
403
+ @app.get("/api/jobs/{job_id}")
404
+ def get_job(
405
+ job_id: str,
406
+ x_broker_token: Optional[str] = Header(default=None),
407
+ ):
408
+ verify_broker_token(x_broker_token)
409
+
410
+ if job_id not in jobs:
411
+ raise HTTPException(status_code=404, detail="Job not found")
412
+
413
+ return jobs[job_id]
414
+
415
+
416
+ # ---------------------------------------------------------------------
417
+ # API: clear jobs
418
+ # ---------------------------------------------------------------------
419
+
420
+ @app.post("/api/clear")
421
+ def clear_jobs(x_broker_token: Optional[str] = Header(default=None)):
422
+ verify_broker_token(x_broker_token)
423
+
424
+ jobs.clear()
425
+
426
+ return {
427
+ "status": "cleared",
428
+ "jobs_count": len(jobs),
429
+ }