Skip to content

Commit 19612de

Browse files
authored
Deploy frame via agent (#142)
1 parent 560107a commit 19612de

File tree

15 files changed

+406
-185
lines changed

15 files changed

+406
-185
lines changed

Dockerfile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,21 @@ RUN apt-get remove -y nodejs curl build-essential libffi-dev ca-certificates gnu
6161
&& rm -rf /app/frontend/node_modules \
6262
&& rm -rf /var/lib/apt/lists/* /root/.npm
6363

64-
# Change back to the main directory
64+
# Install frameos nim deps
6565
WORKDIR /app/frameos
6666

6767
COPY frameos/frameos.nimble ./
6868
COPY frameos/nimble.lock ./
6969
COPY frameos/nim.cfg ./
7070

71+
RUN nimble install -d -y && nimble setup
72+
73+
# Install frameos agent nim deps
74+
WORKDIR /app/agent
75+
76+
COPY agent/frameos_agent.nimble ./
77+
COPY agent/nimble.lock ./
78+
7179
# Cache nimble deps for when deploying on frame
7280
RUN nimble install -d -y && nimble setup
7381

agent/frameos_agent.service

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ RestartSec=5
1414
LimitNOFILE=65536
1515
PrivateTmp=yes
1616
ProtectSystem=full
17+
ReadWritePaths=/etc/systemd/system /etc/cron.d /boot /boot/firmware
1718

1819
[Install]
1920
WantedBy=multi-user.target

agent/src/frameos_agent.nim

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import std/[algorithm, segfaults, strformat, strutils, asyncdispatch, terminal,
2-
times, os, sysrand, httpclient, osproc, streams, unicode]
2+
times, os, sysrand, httpclient, osproc, streams, unicode, typedthreads]
33
import checksums/md5
44
import json, jsony
55
import ws
@@ -52,6 +52,14 @@ type
5252
network*: NetworkConfig
5353
agent*: AgentConfig
5454

55+
WhichStream* = enum stdoutStr, stderrStr
56+
57+
LineMsg* = object
58+
stream*: WhichStream ## stdout or stderr
59+
txt*: string ## one complete line
60+
done*: bool ## true when process finished
61+
exit*: int ## exit code (only valid if done)
62+
5563
# ----------------------------------------------------------------------------
5664
# Config IO (fails hard if unreadable)
5765
# ----------------------------------------------------------------------------
@@ -199,7 +207,63 @@ proc recvBinary(ws: WebSocket): Future[string] {.async.} =
199207
discard # ignore text, pong …
200208

201209
# ----------------------------------------------------------------------------
202-
# Challenge-response handshake (server-initiated)
210+
# Shell command helpers
211+
# ----------------------------------------------------------------------------
212+
type
213+
StreamParams = tuple[
214+
pipe: Stream;
215+
which: WhichStream;
216+
ch: ptr Channel[LineMsg]
217+
]
218+
219+
OutParams = tuple[
220+
process: Process;
221+
ch: ptr Channel[LineMsg]
222+
]
223+
224+
proc readErr(p: StreamParams) {.thread.} =
225+
let (pipe, which, ch) = p
226+
var line: string
227+
while pipe.readLine(line):
228+
ch[].send LineMsg(stream: which, txt: line, done: false)
229+
230+
proc readOut(p: OutParams) {.thread.} =
231+
let (process, ch) = p
232+
var line: string
233+
while process.outputStream.readLine(line):
234+
ch[].send LineMsg(stream: stdoutStr, txt: line, done: false)
235+
let rc = process.waitForExit() # EOF ⇒ child quit
236+
ch[].send LineMsg(done: true, exit: rc) # final signal
237+
238+
proc execShellThreaded(rawCmd: string; ws: WebSocket;
239+
cfg: FrameConfig; id: string): Future[void] {.async.} =
240+
var ch: Channel[LineMsg]
241+
ch.open()
242+
var p = startProcess("/bin/bash",
243+
args = ["-c", rawCmd],
244+
options = {poUsePath})
245+
246+
var thrOut: Thread[OutParams]
247+
var thrErr: Thread[StreamParams]
248+
249+
createThread(thrOut, readOut, (p, addr ch)) # stdout + exit code
250+
createThread(thrErr, readErr, (p.errorStream, stderrStr, addr ch))
251+
252+
while true:
253+
let (ready, msg) = tryRecv(ch) # new one-arg form
254+
if ready:
255+
if msg.done:
256+
await sendResp(ws, cfg, id, msg.exit == 0, %*{"exit": msg.exit})
257+
break
258+
else:
259+
let which = if msg.stream == stdoutStr: "stdout" else: "stderr"
260+
await streamChunk(ws, cfg, id, which, msg.txt & "\n")
261+
else:
262+
await sleepAsync(100)
263+
ch.close()
264+
265+
# ----------------------------------------------------------------------------
266+
# All command handlers
203267
# ----------------------------------------------------------------------------
204268
proc handleCmd(cmd: JsonNode; ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
205269
let id = cmd{"id"}.getStr()
@@ -210,7 +274,7 @@ proc handleCmd(cmd: JsonNode; ws: WebSocket; cfg: FrameConfig): Future[void] {.a
210274

211275
# No remote execution available
212276
if not cfg.agent.agentRunCommands:
213-
if name != "version":
277+
if name != "version": # only allow "version" command
214278
await sendResp(ws, cfg, id, false, %*{"error": "agentRunCommands disabled in config"})
215279
return
216280

@@ -265,31 +329,9 @@ proc handleCmd(cmd: JsonNode; ws: WebSocket; cfg: FrameConfig): Future[void] {.a
265329
"binary": false})
266330
of "shell":
267331
if not args.hasKey("cmd"):
268-
await sendResp(ws, cfg, id, false,
269-
%*{"error": "`cmd` missing"})
270-
return
271-
272-
let cmdStr = args["cmd"].getStr
273-
274-
var p = startProcess(
275-
"/bin/sh", # command
276-
args = ["-c", cmdStr], # argv
277-
options = {poUsePath, poStdErrToStdOut}
278-
)
279-
280-
let bufSize = 4096
281-
var buf = newString(bufSize)
282-
283-
while true:
284-
let n = p.outputStream.readData(addr buf[0], buf.len)
285-
if n == 0:
286-
if p.running: await sleepAsync(100) # no data yet – yield
287-
else: break # process finished – exit loop
288-
else:
289-
await streamChunk(ws, cfg, id, "stdout", buf[0 ..< n])
290-
291-
let rc = p.waitForExit()
292-
await sendResp(ws, cfg, id, rc == 0, %*{"exit": rc})
332+
await sendResp(ws, cfg, id, false, %*{"error": "`cmd` missing"})
333+
else:
334+
asyncCheck execShellThreaded(args["cmd"].getStr(), ws, cfg, id)
293335

294336
of "file_md5":
295337
let path = args{"path"}.getStr("")
@@ -452,14 +494,11 @@ proc doHandshake(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
452494
echo &"⚠️ handshake failed, unexpected action: {act} in {ackMsg}"
453495
raise newException(Exception, "Handshake failed: " & ackMsg)
454496

455-
# ----------------------------------------------------------------------------
456-
# Heartbeat helper
457-
# ----------------------------------------------------------------------------
458497
proc startHeartbeat(ws: WebSocket; cfg: FrameConfig): Future[void] {.async.} =
459498
## Keeps server-side idle-timeout at bay.
460499
try:
461500
while true:
462-
await sleepAsync(20_000)
501+
await sleepAsync(40_000)
463502
let env = makeSecureEnvelope(%*{"type": "heartbeat"}, cfg)
464503
await ws.send($env)
465504
except Exception: discard # will quit when ws closes / errors out
@@ -483,7 +522,7 @@ proc runAgent(cfg: FrameConfig) {.async.} =
483522
await doHandshake(ws, cfg) # throws on failure
484523
backoff = InitialBackoffSeconds # reset back-off
485524

486-
asyncCheck startHeartbeat(ws, cfg) # fire-and-forget
525+
asyncCheck startHeartbeat(ws, cfg)
487526

488527
# ── Main receive loop ───────────────────────────────────────────────
489528
while true:

backend/app/api/frames.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -707,11 +707,7 @@ async def api_frame_get_assets(
707707

708708
ssh = await get_ssh_connection(db, redis, frame)
709709
try:
710-
cmd = (
711-
f"find {shlex.quote(assets_path)} \
712-
\( -type f -o -type d \) \
713-
-exec stat --printf='%F|%s|%Y|%n\n' {{}} +"
714-
)
710+
cmd = f"find {shlex.quote(assets_path)} -exec stat --printf='%F|%s|%Y|%n\\n' {{}} +"
715711
output: list[str] = []
716712
await exec_command(db, redis, frame, ssh, cmd, output, log_output=False)
717713
finally:
@@ -725,12 +721,13 @@ async def api_frame_get_assets(
725721
if len(parts) != 4:
726722
continue
727723
ftype, s, m, p = parts
728-
assets.append({
729-
"path": p.strip(),
730-
"size": int(s),
731-
"mtime": int(m),
732-
"is_dir": ftype == "directory",
733-
})
724+
if p.strip() != assets_path:
725+
assets.append({
726+
"path": p.strip(),
727+
"size": int(s),
728+
"mtime": int(m),
729+
"is_dir": ftype == "directory",
730+
})
734731
assets.sort(key=lambda a: a["path"])
735732
return {"assets": assets}
736733

@@ -745,14 +742,7 @@ async def api_frame_assets_sync(
745742
try:
746743
from app.models.assets import sync_assets
747744

748-
if await _use_agent(frame, redis):
749-
await sync_assets(db, redis, frame, None)
750-
else:
751-
ssh = await get_ssh_connection(db, redis, frame)
752-
try:
753-
await sync_assets(db, redis, frame, ssh)
754-
finally:
755-
await remove_ssh_connection(db, redis, ssh, frame)
745+
await sync_assets(db, redis, frame)
756746
return {"message": "Assets synced successfully"}
757747
except Exception as e:
758748
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))

backend/app/models/assets.py

Lines changed: 16 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import tempfile
21
import uuid
32
import os
4-
import asyncssh
3+
54
from sqlalchemy import LargeBinary, String, Text
65
from sqlalchemy.orm import Session, mapped_column
76
from app.models.frame import Frame
87
from app.database import Base
98
from arq import ArqRedis as Redis
10-
from app.utils.remote_exec import _use_agent, run_commands
11-
from app.utils.ssh_utils import exec_command
9+
from app.utils.remote_exec import _use_agent, run_commands, run_command, upload_file
1210
from app.models.log import new_log as log
1311

1412
default_assets_path = "/srv/assets"
@@ -26,13 +24,13 @@ def to_dict(self):
2624
'size': len(self.data) if self.data else 0,
2725
}
2826

29-
async def sync_assets(db: Session, redis: Redis, frame: Frame, ssh=None):
27+
async def sync_assets(db: Session, redis: Redis, frame: Frame):
3028
assets_path = frame.assets_path or default_assets_path
31-
await make_asset_folders(db, redis, frame, ssh, assets_path)
29+
await make_asset_folders(db, redis, frame, assets_path)
3230
if frame.upload_fonts != "none":
33-
await upload_font_assets(db, redis, frame, ssh, assets_path)
31+
await upload_font_assets(db, redis, frame, assets_path)
3432

35-
async def make_asset_folders(db: Session, redis: Redis, frame: Frame, ssh, assets_path: str):
33+
async def make_asset_folders(db: Session, redis: Redis, frame: Frame, assets_path: str):
3634
if frame.upload_fonts != "none":
3735
cmd = (
3836
f"if [ ! -d {assets_path}/fonts ]; then "
@@ -52,23 +50,19 @@ async def make_asset_folders(db: Session, redis: Redis, frame: Frame, ssh, asset
5250
f"fi"
5351
)
5452

55-
if await _use_agent(frame, redis):
56-
await run_commands(db, redis, frame, [cmd])
57-
else:
58-
await exec_command(db, redis, frame, ssh, cmd)
53+
await run_commands(db, redis, frame, [cmd])
5954

60-
async def upload_font_assets(db: Session, redis: Redis, frame: Frame, ssh, assets_path: str):
55+
async def upload_font_assets(db: Session, redis: Redis, frame: Frame, assets_path: str):
6156
if await _use_agent(frame, redis):
6257
from app.ws.agent_ws import assets_list_on_frame
6358
assets = await assets_list_on_frame(frame.id, assets_path + "/fonts")
6459
remote_fonts = {a["path"]: int(a.get("size", 0)) for a in assets}
6560
else:
6661
command = f"find {assets_path}/fonts -type f -exec stat --format='%s %Y %n' {{}} +"
67-
output: list[str] = []
68-
await exec_command(db, redis, frame, ssh, command, output, log_output=False)
69-
62+
status, stdout, _ = await run_command(db, redis, frame, command)
63+
stdout_lines = stdout.splitlines()
7064
remote_fonts = {}
71-
for line in output:
65+
for line in stdout_lines:
7266
if not line:
7367
continue
7468
size, mtime, path = line.split(' ', 2)
@@ -119,28 +113,17 @@ async def upload_font_assets(db: Session, redis: Redis, frame: Frame, ssh, asset
119113
await file_write_on_frame(frame.id, remote_path, font.data)
120114

121115
else:
122-
await log(db, redis, frame.id, "stdout", f"Uploading {len(fonts_to_upload) + len(custom_fonts_to_upload)} fonts")
123116
for local_path, remote_path in fonts_to_upload:
124-
await asyncssh.scp(
125-
local_path,
126-
(ssh, remote_path),
127-
recurse=False,
128-
)
117+
with open(local_path, "rb") as fh:
118+
data = fh.read()
119+
await upload_file(db, redis, frame, remote_path, data)
129120
for font, remote_path in custom_fonts_to_upload:
130-
with tempfile.NamedTemporaryFile(suffix=".ttf", delete=False) as tmpf:
131-
tempfile_path = tmpf.name
132-
tmpf.write(font.data)
133-
await asyncssh.scp(
134-
tempfile_path,
135-
(ssh, remote_path),
136-
recurse=False,
137-
)
138-
os.remove(tempfile_path)
121+
await upload_file(db, redis, frame, remote_path, font.data)
139122

140123
await log(
141124
db,
142125
redis,
143126
frame.id,
144127
"stdout",
145128
f"Uploaded {len(fonts_to_upload) + len(custom_fonts_to_upload)} fonts",
146-
)
129+
)

backend/app/tasks/deploy_agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
get_ssh_connection,
2020
exec_command,
2121
remove_ssh_connection,
22-
exec_local_command,
2322
)
23+
from app.utils.local_exec import exec_local_command
2424
from .utils import find_nim_v2, find_nimbase_file
2525

2626

0 commit comments

Comments
 (0)