Skip to content

Commit ad19318

Browse files
committed
updating with most recent code and syncing versions
1 parent 34cb8d0 commit ad19318

File tree

4 files changed

+255
-8
lines changed

4 files changed

+255
-8
lines changed

Dockerfile

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
# Python version can be changed, e.g.
2-
# FROM python:3.8
3-
# FROM ghcr.io/mamba-org/micromamba:1.5.1-focal-cuda-11.3.1
41
FROM docker.io/python:3.12.1-slim-bookworm
52

63
LABEL org.opencontainers.image.authors="FedMed Demo" \
@@ -13,6 +10,20 @@ WORKDIR ${SRCDIR}
1310
COPY requirements.txt .
1411
RUN --mount=type=cache,sharing=private,target=/root/.cache/pip pip install -r requirements.txt
1512

13+
# NEW: install ssh client + autossh for reverse tunnels
14+
RUN apt-get update && apt-get install -y --no-install-recommends \
15+
openssh-client autossh ca-certificates && \
16+
rm -rf /var/lib/apt/lists/*
17+
18+
RUN apt-get update && apt-get install -y --no-install-recommends \
19+
openssh-client autossh ca-certificates && \
20+
rm -rf /var/lib/apt/lists/*
21+
22+
# IMPORTANT: ensure UID 1001 exists for Apptainer/CUBE runtime
23+
RUN if ! getent passwd 1001 >/dev/null 2>&1; then \
24+
useradd -u 1001 -m appuser; \
25+
fi
26+
1627
COPY . .
1728
ARG extras_require=none
1829
RUN pip install .[${extras_require}] \

app.py

Lines changed: 239 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
from chris_plugin import chris_plugin
2020

21-
__version__ = "0.1.4"
21+
__version__ = "0.1.7"
2222

2323
APP_PACKAGE = "fedmed_flower_app"
2424
APP_DIR = Path(resources.files(APP_PACKAGE))
@@ -81,6 +81,44 @@ def build_parser() -> ArgumentParser:
8181
help="fraction of clients used for evaluation",
8282
)
8383
parser.add_argument("--json", action="store_true", help=argparse.SUPPRESS)
84+
85+
# NEW optional bastion-related arguments (for reverse tunnelling)
86+
parser.add_argument("--bastion-host", default=None, help="SSH bastion hostname for reverse tunneling")
87+
parser.add_argument("--bastion-user", default=None, help="SSH user on bastion")
88+
parser.add_argument("--bastion-port", type=int, default=22, help="SSH port on bastion")
89+
90+
parser.add_argument(
91+
"--bastion-key",
92+
type=str,
93+
default="id_ed25519",
94+
help="path to SSH private key inside container",
95+
)
96+
parser.add_argument(
97+
"--bastion-known-hosts",
98+
type=str,
99+
default="known_hosts",
100+
help="path to known_hosts file; if missing, host key checking is relaxed",
101+
)
102+
103+
parser.add_argument(
104+
"--bastion-fleet-port",
105+
type=int,
106+
default=19092,
107+
help="remote port on bastion forwarding to Fleet API",
108+
)
109+
parser.add_argument(
110+
"--bastion-control-port",
111+
type=int,
112+
default=19093,
113+
help="remote port on bastion forwarding to Control API",
114+
)
115+
parser.add_argument(
116+
"--bastion-serverapp-port",
117+
type=int,
118+
default=19091,
119+
help="remote port on bastion forwarding to ServerAppIo",
120+
)
121+
84122
parser.add_argument(
85123
"-V",
86124
"--version",
@@ -90,6 +128,7 @@ def build_parser() -> ArgumentParser:
90128
return parser
91129

92130

131+
93132
parser = build_parser()
94133

95134

@@ -171,6 +210,175 @@ def _prepare_environment(state_dir: str) -> tuple[dict[str, str], Path]:
171210
env["FLWR_HOME"] = str(flwr_home)
172211
return env, flwr_home
173212

213+
def _resolve_input_file(inputdir: Path, raw_path: str) -> Path | None:
214+
"""
215+
Resolve a file that is supposed to live under the plugin's input directory.
216+
217+
Cases:
218+
- relative path: look in inputdir / raw_path, else search by basename
219+
- absolute path starting with /incoming: treat it as a hint and search
220+
under inputdir for the same basename
221+
- any other absolute path: only use it if it actually exists
222+
"""
223+
base = inputdir
224+
p = Path(raw_path)
225+
226+
# Case 1: relative path like "id_ed25519" or "keys/id_ed25519"
227+
if not p.is_absolute():
228+
candidate = base / p
229+
if candidate.is_file():
230+
print(f"[fedmed-pl-superlink] using bastion file: {candidate}", flush=True)
231+
return candidate
232+
233+
# try by basename anywhere under inputdir
234+
basename = p.name
235+
matches = list(base.rglob(basename))
236+
if matches:
237+
chosen = matches[0]
238+
print(
239+
f"[fedmed-pl-superlink] resolved {raw_path} -> {chosen} (found under {base})",
240+
flush=True,
241+
)
242+
return chosen
243+
244+
print(
245+
f"[fedmed-pl-superlink] ERROR: could not find {raw_path} under {base}",
246+
flush=True,
247+
)
248+
return None
249+
250+
# Case 2: absolute path under /incoming – treat as hint
251+
if str(p).startswith("/incoming/"):
252+
basename = p.name
253+
if base.exists():
254+
matches = list(base.rglob(basename))
255+
if matches:
256+
chosen = matches[0]
257+
print(
258+
f"[fedmed-pl-superlink] resolved {raw_path} -> {chosen} (found under {base})",
259+
flush=True,
260+
)
261+
return chosen
262+
print(
263+
f"[fedmed-pl-superlink] ERROR: {raw_path} not usable and nothing named {basename} under {base}",
264+
flush=True,
265+
)
266+
return None
267+
268+
# Case 3: other absolute path – only accept if it actually exists
269+
if p.is_file():
270+
print(f"[fedmed-pl-superlink] using bastion file (absolute): {p}", flush=True)
271+
return p
272+
273+
print(
274+
f"[fedmed-pl-superlink] ERROR: absolute path {raw_path} does not exist",
275+
flush=True,
276+
)
277+
return None
278+
279+
# Added for reverse tunelling
280+
def _maybe_open_reverse_tunnels(
281+
options: Namespace,
282+
inputdir: Path,
283+
fleet_local: str,
284+
control_local: str,
285+
serverapp_local: str,
286+
) -> Process | None:
287+
"""Optionally open reverse SSH tunnels from this container to a bastion.
288+
289+
Exposes bastion:<bastion_*_port> -> container:<local ports>.
290+
If bastion_host or bastion_user is unset, this is a no-op.
291+
"""
292+
bastion_host = options.bastion_host
293+
bastion_user = options.bastion_user
294+
if not bastion_host or not bastion_user:
295+
return None
296+
297+
# Resolve original key under /share/incoming (read-only bind mount)
298+
orig_key_path = _resolve_input_file(inputdir, options.bastion_key)
299+
if orig_key_path is None:
300+
print("[fedmed-pl-superlink] WARNING: no valid SSH key found; skipping reverse tunnels", flush=True)
301+
return None
302+
303+
# 🔑 Copy key to a writable location and fix permissions there
304+
keys_dir = Path("/tmp/fedmed-ssh")
305+
keys_dir.mkdir(parents=True, exist_ok=True)
306+
307+
key_path = keys_dir / orig_key_path.name
308+
try:
309+
shutil.copy2(orig_key_path, key_path)
310+
key_path.chmod(0o600)
311+
print(
312+
f"[fedmed-pl-superlink] copied bastion key to {key_path} and set permissions 0600",
313+
flush=True,
314+
)
315+
except Exception as exc:
316+
print(
317+
f"[fedmed-pl-superlink] ERROR: failed to copy/chmod bastion key: {exc}",
318+
flush=True,
319+
)
320+
return None
321+
322+
# Optional: handle known_hosts similarly
323+
known_hosts_path = None
324+
if options.bastion_known_hosts:
325+
orig_known = _resolve_input_file(inputdir, options.bastion_known_hosts)
326+
if orig_known and orig_known.is_file():
327+
try:
328+
known_hosts_path = keys_dir / "known_hosts"
329+
shutil.copy2(orig_known, known_hosts_path)
330+
print(
331+
f"[fedmed-pl-superlink] copied known_hosts to {known_hosts_path}",
332+
flush=True,
333+
)
334+
except Exception as exc:
335+
print(
336+
f"[fedmed-pl-superlink] WARNING: failed to copy known_hosts: {exc}",
337+
flush=True,
338+
)
339+
known_hosts_path = None
340+
341+
ssh_opts: list[str] = [
342+
"-o", "ServerAliveInterval=30",
343+
"-o", "ServerAliveCountMax=3",
344+
"-o", "ExitOnForwardFailure=yes",
345+
]
346+
347+
if known_hosts_path and known_hosts_path.exists():
348+
ssh_opts.extend([
349+
"-o", f"UserKnownHostsFile={known_hosts_path}",
350+
"-o", "StrictHostKeyChecking=yes",
351+
])
352+
else:
353+
ssh_opts.extend(["-o", "StrictHostKeyChecking=no"])
354+
355+
cmd: list[str] = [
356+
"ssh",
357+
"-vv",
358+
"-N",
359+
*ssh_opts,
360+
"-p", str(options.bastion_port),
361+
"-i", str(key_path),
362+
"-R", f"0.0.0.0:{options.bastion_fleet_port}:{fleet_local}",
363+
"-R", f"0.0.0.0:{options.bastion_control_port}:{control_local}",
364+
"-R", f"0.0.0.0:{options.bastion_serverapp_port}:{serverapp_local}",
365+
f"{bastion_user}@{bastion_host}",
366+
]
367+
368+
#_check_superlink_reachable(options.superlink_host, options.bastion_port)
369+
370+
print(f"[fedmed-pl-superlink] opening reverse tunnels: {' '.join(cmd)}", flush=True)
371+
proc = subprocess.Popen(
372+
cmd,
373+
stdout=subprocess.PIPE,
374+
stderr=subprocess.PIPE,
375+
text=True,
376+
)
377+
_register_child(proc)
378+
threading.Thread(target=_stream_lines, args=(proc.stdout, "ssh"), daemon=True).start()
379+
threading.Thread(target=_stream_lines, args=(proc.stderr, "ssh"), daemon=True).start()
380+
return proc
381+
174382

175383
def handle_signals() -> None:
176384
def _handle(signum, _frame): # type: ignore[override]
@@ -289,9 +497,21 @@ def main(options: Namespace, inputdir: Path, outputdir: Path) -> None:
289497
"===============================\n",
290498
flush=True,
291499
)
292-
del inputdir
293500
handle_signals()
294501

502+
# DEBUG: show what pl-dircopy actually put into /incoming
503+
print(f"[fedmed-pl-superlink] DEBUG: inputdir = {inputdir}", flush=True)
504+
root = inputdir
505+
if not root.exists():
506+
print(f"[fedmed-pl-superlink] DEBUG: {root} does not exist", flush=True)
507+
else:
508+
for p in root.rglob("*"):
509+
try:
510+
rel = p.relative_to(root)
511+
except ValueError:
512+
rel = p
513+
print(f" {root}/{rel}", flush=True)
514+
295515
if getattr(options, "json", False):
296516
emit_plugin_json()
297517
return
@@ -330,11 +550,27 @@ def main(options: Namespace, inputdir: Path, outputdir: Path) -> None:
330550
+ ", ".join(reachable_ips),
331551
flush=True,
332552
)
553+
# Use the first container IP as the target for SSH -R
554+
tunnel_ip = reachable_ips[0]
555+
print(
556+
f"[fedmed-pl-superlink] using {tunnel_ip} as SSH -R backend target",
557+
flush=True,
558+
)
333559
else:
334560
print(
335-
"[fedmed-pl-superlink] unable to auto-detect host IPs.",
561+
"[fedmed-pl-superlink] unable to auto-detect host IPs; "
562+
"falling back to 127.0.0.1 for SSH -R backend (may fail on host)",
336563
flush=True,
337564
)
565+
tunnel_ip = "127.0.0.1"
566+
567+
# Local targets *from the EC2 host's perspective* for SSH -R
568+
fleet_local = f"{tunnel_ip}:{options.fleet_port}"
569+
control_local = f"{tunnel_ip}:{options.control_port}"
570+
serverapp_local = f"{tunnel_ip}:{options.serverapp_port}"
571+
572+
# Open reverse tunnels to bastion (no-op if bastion_* not set)
573+
_maybe_open_reverse_tunnels(options, inputdir, fleet_local, control_local, serverapp_local)
338574

339575
superlink = _launch_superlink(addresses, env)
340576
time.sleep(max(0, options.startup_delay))

fedmed_flower_app/fedmed_flower_app/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
"server_app",
1111
]
1212

13-
__version__ = "0.1.4"
13+
__version__ = "0.1.7"

fedmed_flower_app/fedmed_flower_app/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "fedmed-flower-app"
7-
version = "0.1.4"
7+
version = "0.1.7"
88
description = "FedMed Flower App"
99
license = {text = "MIT"}
1010
authors = [{name = "FedMed Team", email = "[email protected]"}]

0 commit comments

Comments
 (0)