Skip to content

Commit 53a9dea

Browse files
feat(windows): add client launcher exe pipeline and docs
1 parent 00a030b commit 53a9dea

5 files changed

Lines changed: 384 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Windows Client EXE Build
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches: [main]
7+
paths:
8+
- windows/**
9+
- src/client.py
10+
- .github/workflows/windows-client-exe.yml
11+
pull_request:
12+
branches: [main]
13+
paths:
14+
- windows/**
15+
- src/client.py
16+
- .github/workflows/windows-client-exe.yml
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
build-windows-exe:
23+
name: Build Windows Client EXE
24+
if: github.event_name != 'pull_request' || github.actor != 'dependabot[bot]'
25+
runs-on: windows-latest
26+
timeout-minutes: 60
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
31+
32+
- name: Set up Python 3.11
33+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
34+
with:
35+
python-version: '3.11'
36+
37+
- name: Build Windows executable (PyInstaller)
38+
shell: pwsh
39+
run: |
40+
powershell -ExecutionPolicy Bypass -File windows/build_windows_client_exe.ps1 -OneDir
41+
42+
- name: Upload Windows client artifact
43+
uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808
44+
with:
45+
name: sovereignmap-windows-client-exe
46+
path: |
47+
dist/SovereignMapClient/**
48+
dist/SovereignMapClient.exe
49+
if-no-files-found: error

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ output/
196196
*.out
197197
*.tmp
198198
/sovereign-node
199+
participants/
199200

200201
# Test results
201202
test-results/
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Windows EXE Client Launcher
2+
3+
This guide packages a Windows executable that can register and connect your laptop as a federated learning client.
4+
5+
## What it does
6+
7+
- Performs join bootstrap against the backend Join API
8+
- Saves certificate bundle and registration output locally
9+
- Prints acceleration diagnostics (NPU, GPU, CPU)
10+
- Starts FL client and connects to Flower aggregator
11+
12+
## Build on Windows
13+
14+
Open PowerShell in the repository root and run:
15+
16+
```powershell
17+
powershell -ExecutionPolicy Bypass -File windows/build_windows_client_exe.ps1
18+
```
19+
20+
Output executable:
21+
22+
- dist/SovereignMapClient.exe
23+
24+
## Run the EXE
25+
26+
With an invite code:
27+
28+
```powershell
29+
.\dist\SovereignMapClient.exe `
30+
--backend-url http://YOUR_COORDINATOR_HOST:8000 `
31+
--participant-name laptop-client `
32+
--invite-code YOUR_INVITE_CODE
33+
```
34+
35+
For local dev bootstrap (admin token path):
36+
37+
```powershell
38+
.\dist\SovereignMapClient.exe `
39+
--backend-url http://localhost:8000 `
40+
--participant-name laptop-client `
41+
--admin-token local-dev-admin-token
42+
```
43+
44+
Skip join bootstrap and connect directly:
45+
46+
```powershell
47+
.\dist\SovereignMapClient.exe --skip-bootstrap --aggregator YOUR_HOST:8080 --node-id 77
48+
```
49+
50+
## NPU and GPU diagnostics
51+
52+
At startup the EXE prints:
53+
54+
- torch version
55+
- CUDA availability and device names
56+
- NPU availability if torch.npu is present
57+
- selected device and probe result
58+
59+
If acceleration is unavailable, it falls back to CPU and prints a warning.
60+
61+
## Output artifacts on laptop
62+
63+
Default output directory:
64+
65+
- %USERPROFILE%\SovereignMapClient\<participant-name>\
66+
67+
Generated files:
68+
69+
- join-registration.json
70+
- certs/node-cert.pem
71+
- certs/node-key.pem
72+
- certs/ca-cert.pem
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
$ErrorActionPreference = "Stop"
2+
3+
param(
4+
[string]$VenvDir = ".venv-windows-client",
5+
[string]$ExeName = "SovereignMapClient",
6+
[switch]$OneDir
7+
)
8+
9+
Write-Host "Building Windows client EXE..."
10+
11+
if (-not (Test-Path $VenvDir)) {
12+
py -3.11 -m venv $VenvDir
13+
}
14+
15+
$PythonExe = Join-Path $VenvDir "Scripts/python.exe"
16+
$PipExe = Join-Path $VenvDir "Scripts/pip.exe"
17+
18+
& $PipExe install --upgrade pip setuptools wheel
19+
& $PipExe install pyinstaller
20+
& $PipExe install flwr==1.7.0 torch==2.1.0 torchvision==0.16.0 opacus==1.4.0 numpy==1.24.3
21+
22+
$mode = "--onefile"
23+
if ($OneDir) {
24+
$mode = "--onedir"
25+
}
26+
27+
& $PythonExe -m PyInstaller $mode --clean --noconfirm `
28+
--name $ExeName `
29+
--hidden-import src.client `
30+
--collect-submodules flwr `
31+
--collect-submodules opacus `
32+
windows/client_launcher.py
33+
34+
Write-Host "Build complete. Output in dist/$ExeName"
35+
Write-Host "Example run:"
36+
Write-Host " .\\dist\\$ExeName.exe --backend-url http://<host>:8000 --participant-name laptop --invite-code <code>"

windows/client_launcher.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Windows launcher for Sovereign Map FL client.
4+
5+
Features:
6+
- Optional self-serve join bootstrap (invite/register)
7+
- Saves cert bundle and registration JSON locally
8+
- Prints NPU/GPU/CPU acceleration diagnostics
9+
- Connects to Flower aggregator using existing client runtime
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import json
16+
import os
17+
import pathlib
18+
import sys
19+
import urllib.error
20+
import urllib.request
21+
from typing import Any, Dict, Optional
22+
23+
24+
def _post_json(url: str, payload: Dict[str, Any], headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
25+
data = json.dumps(payload).encode("utf-8")
26+
req_headers = {"Content-Type": "application/json"}
27+
if headers:
28+
req_headers.update(headers)
29+
30+
req = urllib.request.Request(url, data=data, headers=req_headers, method="POST")
31+
with urllib.request.urlopen(req, timeout=20) as response:
32+
return json.loads(response.read().decode("utf-8"))
33+
34+
35+
def _bootstrap_join(
36+
backend_url: str,
37+
participant_name: str,
38+
invite_code: Optional[str],
39+
admin_token: Optional[str],
40+
out_dir: pathlib.Path,
41+
) -> Dict[str, Any]:
42+
out_dir.mkdir(parents=True, exist_ok=True)
43+
cert_dir = out_dir / "certs"
44+
cert_dir.mkdir(parents=True, exist_ok=True)
45+
46+
code = invite_code
47+
if not code:
48+
if not admin_token:
49+
raise RuntimeError("invite code is required unless admin token is provided")
50+
invite = _post_json(
51+
f"{backend_url}/join/invite",
52+
{
53+
"participant_name": participant_name,
54+
"max_uses": 1,
55+
"expires_in_hours": 24,
56+
},
57+
headers={"X-Join-Admin-Token": admin_token},
58+
)
59+
code = invite.get("invite_code")
60+
if not code:
61+
raise RuntimeError(f"join invite failed: {invite}")
62+
63+
registration = _post_json(
64+
f"{backend_url}/join/register",
65+
{"invite_code": code, "participant_name": participant_name},
66+
)
67+
68+
certificates = registration.get("certificates", {})
69+
(cert_dir / "node-cert.pem").write_text(certificates.get("node_cert_pem", ""), encoding="utf-8")
70+
(cert_dir / "node-key.pem").write_text(certificates.get("node_key_pem", ""), encoding="utf-8")
71+
(cert_dir / "ca-cert.pem").write_text(certificates.get("ca_cert_pem", ""), encoding="utf-8")
72+
(out_dir / "join-registration.json").write_text(
73+
json.dumps(registration, indent=2), encoding="utf-8"
74+
)
75+
76+
return registration
77+
78+
79+
def _apply_llm_policy_env(policy: Dict[str, Any]):
80+
os.environ["LLM_ADAPTER_MODEL_FAMILY"] = str(policy.get("model_family", "llama-3.1"))
81+
os.environ["LLM_ADAPTER_MODEL_VERSION"] = str(policy.get("model_version", "8b-instruct"))
82+
os.environ["LLM_ADAPTER_TOKENIZER_HASH"] = str(policy.get("tokenizer_hash", "local-dev-tokenizer-v1"))
83+
84+
ranks = policy.get("allowed_adapter_ranks") or [16]
85+
os.environ["LLM_ADAPTER_RANK"] = str(ranks[0])
86+
87+
modules = policy.get("required_target_modules") or ["q_proj", "v_proj"]
88+
os.environ["LLM_ADAPTER_TARGET_MODULES"] = ",".join(str(m) for m in modules)
89+
90+
91+
def _acceleration_report(torch_mod) -> Dict[str, Any]:
92+
report: Dict[str, Any] = {
93+
"torch_version": torch_mod.__version__,
94+
"cuda_available": bool(torch_mod.cuda.is_available()),
95+
"cuda_device_count": 0,
96+
"cuda_devices": [],
97+
"npu_available": False,
98+
"npu_device": None,
99+
"selected_device": "cpu",
100+
}
101+
102+
if report["cuda_available"]:
103+
count = torch_mod.cuda.device_count()
104+
report["cuda_device_count"] = count
105+
for idx in range(count):
106+
report["cuda_devices"].append(torch_mod.cuda.get_device_name(idx))
107+
if count > 0:
108+
report["selected_device"] = "cuda:0"
109+
110+
if hasattr(torch_mod, "npu"):
111+
try:
112+
if torch_mod.npu.is_available():
113+
report["npu_available"] = True
114+
report["npu_device"] = "npu:0"
115+
report["selected_device"] = "npu:0"
116+
except Exception:
117+
pass
118+
119+
# Probe selected device with a tiny tensor op
120+
selected = report["selected_device"]
121+
try:
122+
tensor = torch_mod.randn((4, 4), device=selected)
123+
_ = tensor @ tensor
124+
report["probe_ok"] = True
125+
except Exception as exc:
126+
report["probe_ok"] = False
127+
report["probe_error"] = str(exc)
128+
report["selected_device"] = "cpu"
129+
130+
return report
131+
132+
133+
def parse_args() -> argparse.Namespace:
134+
parser = argparse.ArgumentParser(description="Sovereign Map Windows FL Client Launcher")
135+
parser.add_argument("--aggregator", default="localhost:8080", help="Aggregator host:port")
136+
parser.add_argument("--node-id", type=int, default=1, help="Node ID for this participant")
137+
parser.add_argument("--byzantine", action="store_true", help="Run as Byzantine test node")
138+
139+
parser.add_argument("--backend-url", default="http://localhost:8000", help="Backend URL for join bootstrap")
140+
parser.add_argument("--participant-name", default="windows-client", help="Participant name for registration")
141+
parser.add_argument("--invite-code", default="", help="Existing invite code")
142+
parser.add_argument("--admin-token", default="", help="Join admin token (for local testing only)")
143+
parser.add_argument("--skip-bootstrap", action="store_true", help="Skip join bootstrap and connect directly")
144+
145+
parser.add_argument(
146+
"--output-dir",
147+
default=str(pathlib.Path.home() / "SovereignMapClient"),
148+
help="Directory to store certs and registration",
149+
)
150+
return parser.parse_args()
151+
152+
153+
def main() -> int:
154+
args = parse_args()
155+
156+
print("Sovereign Map Windows Client Launcher")
157+
print("-------------------------------------")
158+
159+
registration = None
160+
if not args.skip_bootstrap:
161+
try:
162+
registration = _bootstrap_join(
163+
backend_url=args.backend_url.rstrip("/"),
164+
participant_name=args.participant_name,
165+
invite_code=args.invite_code or None,
166+
admin_token=args.admin_token or None,
167+
out_dir=pathlib.Path(args.output_dir) / args.participant_name,
168+
)
169+
agg = registration.get("aggregator", {})
170+
host = agg.get("host")
171+
port = agg.get("port")
172+
if host and port:
173+
args.aggregator = f"{host}:{port}"
174+
175+
policy = registration.get("llm_policy", {})
176+
if isinstance(policy, dict):
177+
_apply_llm_policy_env(policy)
178+
179+
reg_node = registration.get("registration", {}).get("node_id")
180+
if isinstance(reg_node, int):
181+
args.node_id = reg_node
182+
183+
print(f"Join bootstrap OK: node_id={args.node_id}, aggregator={args.aggregator}")
184+
except (RuntimeError, urllib.error.URLError, TimeoutError) as exc:
185+
print(f"Join bootstrap failed: {exc}")
186+
return 1
187+
188+
try:
189+
import flwr as fl
190+
import torch
191+
from src.client import SovereignClient
192+
except Exception as exc:
193+
print(f"Missing runtime dependency: {exc}")
194+
print("Install dependencies or build with windows/build_windows_client_exe.ps1")
195+
return 1
196+
197+
report = _acceleration_report(torch)
198+
print("Acceleration diagnostics:")
199+
print(json.dumps(report, indent=2))
200+
201+
if report.get("selected_device") == "cpu":
202+
print("Warning: No NPU/GPU acceleration detected; running on CPU.")
203+
204+
print(f"Connecting to aggregator at {args.aggregator} as node {args.node_id}...")
205+
206+
client = SovereignClient(
207+
node_id=args.node_id,
208+
byzantine=args.byzantine,
209+
server_address=args.aggregator,
210+
)
211+
212+
try:
213+
fl.client.start_client(
214+
server_address=args.aggregator,
215+
client=client.to_client(),
216+
grpc_max_message_length=1024 * 1024 * 1024,
217+
)
218+
except Exception as exc:
219+
print(f"Failed to connect to aggregator: {exc}")
220+
return 1
221+
222+
return 0
223+
224+
225+
if __name__ == "__main__":
226+
sys.exit(main())

0 commit comments

Comments
 (0)