Skip to content

Commit 70e4c20

Browse files
committed
Add isolation docs and bundle validation helper
1 parent ed79cbf commit 70e4c20

File tree

4 files changed

+157
-4
lines changed

4 files changed

+157
-4
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,37 @@ When you ask for a new program, you (and I) will produce an **Import Bundle JSON
5454

5555
This ensures every new local program is automatically registered, categorized, assigned a port, and optionally assigned a database.
5656

57+
## Isolation model (recommended)
58+
59+
To keep your controller clean and prevent accidental coupling between programs:
60+
61+
- **Each program lives in its own folder**
62+
- **Each folder is its own Cursor project**
63+
- **Each folder is its own Git repo**
64+
- The controller **never imports program code**. It only references programs by metadata:
65+
- `working_directory`
66+
- `start_command` / `stop_command` / `restart_command`
67+
- ports + URLs + healthcheck URL
68+
- key *references* (env var names only)
69+
70+
### Per-program registration artifact
71+
72+
Each program repo should include a small, versioned bundle file (recommended name: `local-nexus.bundle.json`) that matches the controller’s `ImportBundle` JSON shape.
73+
74+
You can import it with:
75+
76+
```powershell
77+
python .\tools\import_bundle.py C:\path\to\program\local-nexus.bundle.json
78+
```
79+
80+
Or paste the JSON into Dashboard → **Import**.
81+
82+
You can validate a bundle (and optionally normalize Windows paths) with:
83+
84+
```powershell
85+
python .\tools\validate_bundle.py C:\path\to\program\local-nexus.bundle.json
86+
```
87+
5788
## Notes
5889

5990
- The controller stores **only references** to secrets (e.g., `OPENAI_API_KEY`) and where they are used. It never stores secret values.

local_nexus_controller/models.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from __future__ import annotations
2-
31
import uuid
42
from datetime import datetime, timezone
53
from typing import Any, Optional
@@ -79,7 +77,7 @@ class Database(SQLModel, table=True):
7977
created_at: datetime = Field(default_factory=_now_utc)
8078
updated_at: datetime = Field(default_factory=_now_utc)
8179

82-
services: list[Service] = Relationship(back_populates="database")
80+
services: list["Service"] = Relationship(back_populates="database")
8381

8482

8583
class KeyRef(SQLModel, table=True):
@@ -92,7 +90,7 @@ class KeyRef(SQLModel, table=True):
9290

9391
created_at: datetime = Field(default_factory=_now_utc)
9492

95-
service: Service = Relationship(back_populates="keys")
93+
service: "Service" = Relationship(back_populates="keys")
9694

9795

9896
# -----------------------

tools/import_bundle.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
import sys
55
from pathlib import Path
66

7+
# Ensure the repo root is importable even when running a script from /tools.
8+
_REPO_ROOT = Path(__file__).resolve().parents[1]
9+
if str(_REPO_ROOT) not in sys.path:
10+
sys.path.insert(0, str(_REPO_ROOT))
11+
712
from sqlmodel import Session
813

914
from local_nexus_controller.db import engine, init_db

tools/validate_bundle.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import re
6+
import sys
7+
from pathlib import Path
8+
from typing import Any
9+
10+
# Ensure the repo root is importable even when running a script from /tools.
11+
_REPO_ROOT = Path(__file__).resolve().parents[1]
12+
if str(_REPO_ROOT) not in sys.path:
13+
sys.path.insert(0, str(_REPO_ROOT))
14+
15+
from local_nexus_controller.models import ImportBundle
16+
17+
18+
_HAS_PLACEHOLDER_RE = re.compile(r"(\{[A-Z_]+\}|\$\{[A-Z_]+\}|%[A-Z_]+%)")
19+
20+
21+
def _looks_like_windows_abs_path(p: str) -> bool:
22+
# Accept both C:\foo and C:/foo
23+
return bool(re.match(r"^[a-zA-Z]:[\\/]", p))
24+
25+
26+
def _to_posixish(p: str) -> str:
27+
return p.replace("\\", "/")
28+
29+
30+
def normalize_path_value(value: str) -> str:
31+
"""
32+
Normalize Windows path strings to use forward slashes.
33+
If the value looks like an absolute path and does not contain placeholders,
34+
resolve it to an absolute normalized path.
35+
"""
36+
37+
v = str(value)
38+
v = _to_posixish(v)
39+
40+
if _HAS_PLACEHOLDER_RE.search(v):
41+
return v
42+
43+
if _looks_like_windows_abs_path(v):
44+
try:
45+
resolved = Path(v).resolve()
46+
return _to_posixish(str(resolved))
47+
except Exception:
48+
# Best-effort: keep the slash-normalized value
49+
return v
50+
51+
return v
52+
53+
54+
def normalize_bundle_dict(obj: dict[str, Any]) -> dict[str, Any]:
55+
out = json.loads(json.dumps(obj)) # deep copy (keeps only JSON types)
56+
svc = out.get("service") or {}
57+
58+
if isinstance(svc, dict):
59+
wd = svc.get("working_directory")
60+
if isinstance(wd, str) and wd.strip():
61+
svc["working_directory"] = normalize_path_value(wd)
62+
63+
cps = svc.get("config_paths")
64+
if isinstance(cps, list):
65+
svc["config_paths"] = [normalize_path_value(x) if isinstance(x, str) else x for x in cps]
66+
67+
out["service"] = svc
68+
return out
69+
70+
71+
def main() -> int:
72+
ap = argparse.ArgumentParser(description="Validate (and optionally normalize) a Local Nexus Import Bundle JSON.")
73+
ap.add_argument("path", help="Path to a bundle JSON file (single object or list).")
74+
ap.add_argument("--normalize", action="store_true", help="Normalize path separators and resolve absolute paths.")
75+
ap.add_argument(
76+
"--output",
77+
help="Write normalized JSON to this path (requires --normalize). If omitted, prints normalized JSON to stdout.",
78+
)
79+
args = ap.parse_args()
80+
81+
path = Path(args.path).expanduser().resolve()
82+
if not path.exists():
83+
raise SystemExit(f"Bundle not found: {path}")
84+
85+
payload = json.loads(path.read_text(encoding="utf-8"))
86+
items: list[dict[str, Any]]
87+
if isinstance(payload, list):
88+
items = payload
89+
else:
90+
items = [payload]
91+
92+
normalized_items: list[dict[str, Any]] = []
93+
for idx, item in enumerate(items, start=1):
94+
if not isinstance(item, dict):
95+
raise SystemExit(f"Item {idx} is not a JSON object.")
96+
raw = normalize_bundle_dict(item) if args.normalize else item
97+
98+
# Validate against the controller schema (raises on error)
99+
ImportBundle.model_validate(raw) # type: ignore[attr-defined]
100+
normalized_items.append(raw)
101+
102+
print(f"OK: validated {len(normalized_items)} bundle(s) from {path}")
103+
104+
if args.normalize:
105+
output_payload: Any = normalized_items if isinstance(payload, list) else normalized_items[0]
106+
text = json.dumps(output_payload, indent=2, ensure_ascii=False)
107+
if args.output:
108+
out_path = Path(args.output).expanduser().resolve()
109+
out_path.write_text(text + "\n", encoding="utf-8")
110+
print(f"Wrote normalized bundle to: {out_path}")
111+
else:
112+
print(text)
113+
114+
return 0
115+
116+
117+
if __name__ == "__main__":
118+
raise SystemExit(main())
119+

0 commit comments

Comments
 (0)