Merge pull request 'feat(client): workspace bootstrap — what a new computer pulls first' (#4) from feat/workspace-bootstrap into main
This commit is contained in:
@@ -412,7 +412,7 @@ deleted it again, `DELETE …/tokens/{id}` returning 204 under basic auth):
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
* `python3 -m unittest discover -s tests` — 172 tests. The v1.2 additions
|
* `python3 -m unittest discover -s tests` — 184 tests. The v1.2 additions
|
||||||
cover: a snapshot capturing uncommitted work while HEAD, the index and the
|
cover: a snapshot capturing uncommitted work while HEAD, the index and the
|
||||||
working tree stay byte-identical; snapshots landing outside `refs/heads`;
|
working tree stay byte-identical; snapshots landing outside `refs/heads`;
|
||||||
an unchanged tree not being re-pushed; a diverged folder still being backed
|
an unchanged tree not being re-pushed; a diverged folder still being backed
|
||||||
@@ -512,6 +512,35 @@ holding a working forge token, and **the forge decides** whose it is
|
|||||||
(`GET /api/v1/user`). No login is ever read from the request body, so a body
|
(`GET /api/v1/user`). No login is ever read from the request body, so a body
|
||||||
claiming another account changes nothing.
|
claiming another account changes nothing.
|
||||||
|
|
||||||
|
## Workspace bootstrap — what a new computer should pull first
|
||||||
|
|
||||||
|
granthi-sync bootstrap ~/granthi/acme --into ~/work
|
||||||
|
|
||||||
|
A workspace is a repo holding a `workspace.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"name": "acme",
|
||||||
|
"repos": ["notes", {"name": "reports", "mode": "mirror"}],
|
||||||
|
"apps": [{"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b",
|
||||||
|
"setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`bootstrap` pulls the repos it names — **still only the ones the forge grants
|
||||||
|
this account**; a manifest asking for a repo you cannot see prints
|
||||||
|
`NOT GRANTED` and moves on, because that is a permissions answer and not an
|
||||||
|
error to route around.
|
||||||
|
|
||||||
|
**It does not install applications, and that is the design, not a shortcut.**
|
||||||
|
Dash, deck, genie and shiva each have their own repo, deploy path and
|
||||||
|
reviewers. A sync client that installed them would create a second,
|
||||||
|
unreviewed deploy path beside the real one — the same copy-instead-of-refer
|
||||||
|
mistake the estate rulebook exists to end. So `apps` is *referenced*: the
|
||||||
|
command prints each app's repo, the model it uses, its setup doc, and which
|
||||||
|
vault keys must exist, with the `shre-cred request` line to supply them.
|
||||||
|
|
||||||
|
Per-repo `mode` in the manifest overrides the default, so a documents folder
|
||||||
|
can be declared `mirror` while everything else stays on the safe `snapshot`.
|
||||||
|
|
||||||
## Next phase — invites and per-repo access (designed, not built)
|
## Next phase — invites and per-repo access (designed, not built)
|
||||||
|
|
||||||
Today `/v1/link` creates an account and every folder becomes a private repo
|
Today `/v1/link` creates an account and every folder becomes a private repo
|
||||||
|
|||||||
@@ -1228,6 +1228,117 @@ def cmd_activity(args):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
WORKSPACE_FILE = "workspace.json"
|
||||||
|
|
||||||
|
|
||||||
|
def read_workspace(folder):
|
||||||
|
"""Parse a workspace manifest, or raise SystemExit with a usable message.
|
||||||
|
|
||||||
|
Shape (everything except `repos` is optional):
|
||||||
|
|
||||||
|
{"name": "acme",
|
||||||
|
"repos": [{"name": "notes", "mode": "mirror"}, "reports"],
|
||||||
|
"apps": [{"id": "dash", "repo": "nirpa/hermes-agent",
|
||||||
|
"model": "aum/70b", "setup": "docs/SETUP.md",
|
||||||
|
"needs_keys": ["anthropic"]}]}
|
||||||
|
|
||||||
|
`apps` REFERENCES applications; it never vendors them. Dash, deck, genie
|
||||||
|
and shiva each have their own repo, deploy path and reviewers, and copying
|
||||||
|
them into a workspace would fork them silently -- the same
|
||||||
|
copy-without-a-generator failure the estate rulebook exists to end.
|
||||||
|
"""
|
||||||
|
path = os.path.join(folder, WORKSPACE_FILE)
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise SystemExit(f"no {WORKSPACE_FILE} in {folder}")
|
||||||
|
except ValueError as e:
|
||||||
|
raise SystemExit(f"{path} is not valid JSON: {e}")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SystemExit(f"{path} must be a JSON object")
|
||||||
|
repos = data.get("repos") or []
|
||||||
|
if not isinstance(repos, list):
|
||||||
|
raise SystemExit(f"{path}: 'repos' must be a list")
|
||||||
|
norm = []
|
||||||
|
for entry in repos:
|
||||||
|
if isinstance(entry, str):
|
||||||
|
norm.append({"name": entry})
|
||||||
|
elif isinstance(entry, dict) and entry.get("name"):
|
||||||
|
norm.append(entry)
|
||||||
|
else:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{path}: every repo must be a name or an object with 'name'")
|
||||||
|
data["repos"] = norm
|
||||||
|
apps = data.get("apps") or []
|
||||||
|
if not isinstance(apps, list) or any(not isinstance(a, dict) for a in apps):
|
||||||
|
raise SystemExit(f"{path}: 'apps' must be a list of objects")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_bootstrap(args):
|
||||||
|
"""Set a computer up from a workspace manifest.
|
||||||
|
|
||||||
|
Deliberately does NOT install applications. It pulls the repos the
|
||||||
|
workspace names -- still only the ones the forge grants this account --
|
||||||
|
and then TELLS the user what each app needs: its repo, its model, its
|
||||||
|
setup doc, and which vault keys must exist. Installing someone's dash or
|
||||||
|
shiva from a sync client would put a second, unreviewed deploy path next
|
||||||
|
to the real one.
|
||||||
|
"""
|
||||||
|
cfg = require_linked(load_config())
|
||||||
|
folder = os.path.abspath(args.folder)
|
||||||
|
ws = read_workspace(folder)
|
||||||
|
name = ws.get("name") or os.path.basename(folder)
|
||||||
|
into = os.path.abspath(args.into or ".")
|
||||||
|
print(f"workspace: {name}\n")
|
||||||
|
|
||||||
|
granted = {r.get("full_name") for r in list_repos(cfg)[0]}
|
||||||
|
granted |= {(r.get("full_name") or "").rsplit("/", 1)[-1] for r in []}
|
||||||
|
pulled = skipped = denied = 0
|
||||||
|
for repo in ws["repos"]:
|
||||||
|
want = repo["name"]
|
||||||
|
full = want if "/" in want else f"{cfg['login']}/{want}"
|
||||||
|
if full not in granted:
|
||||||
|
# The forge decides. A manifest asking for a repo this account was
|
||||||
|
# not granted is a permissions answer, not an error to route round.
|
||||||
|
log(f"NOT GRANTED {full}: your account cannot see it — ask the "
|
||||||
|
f"workspace owner for access")
|
||||||
|
denied += 1
|
||||||
|
continue
|
||||||
|
dest = os.path.join(into, full.rsplit("/", 1)[-1])
|
||||||
|
if os.path.exists(dest) and os.listdir(dest):
|
||||||
|
log(f"skip {full}: already at {dest}")
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if args.dry_run:
|
||||||
|
log(f"WOULD PULL {full} -> {dest}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
clone_one(cfg, full, dest, repo.get("mode"))
|
||||||
|
pulled += 1
|
||||||
|
except (SystemExit, RuntimeError) as e:
|
||||||
|
log(f"FAILED {full}: {e}")
|
||||||
|
print(f"\nrepos: {pulled} pulled, {skipped} already here, "
|
||||||
|
f"{denied} not granted")
|
||||||
|
|
||||||
|
apps = ws.get("apps") or []
|
||||||
|
if apps:
|
||||||
|
print("\napps this workspace expects (NOT installed by granthi-sync —"
|
||||||
|
"\neach has its own repo and deploy path):")
|
||||||
|
for app in apps:
|
||||||
|
print(f" {app.get('id', '?')}")
|
||||||
|
for label, key in (("repo", "repo"), ("model", "model"),
|
||||||
|
("setup", "setup")):
|
||||||
|
if app.get(key):
|
||||||
|
print(f" {label:6} {app[key]}")
|
||||||
|
keys = app.get("needs_keys") or []
|
||||||
|
if keys:
|
||||||
|
print(f" keys {', '.join(keys)} "
|
||||||
|
f"(add with: shre-cred request --name <key> ...)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _folder_meta(cfg, folder):
|
def _folder_meta(cfg, folder):
|
||||||
path = os.path.abspath(folder)
|
path = os.path.abspath(folder)
|
||||||
meta = cfg.get("folders", {}).get(path)
|
meta = cfg.get("folders", {}).get(path)
|
||||||
@@ -1434,6 +1545,13 @@ def main(argv=None):
|
|||||||
sp.add_argument("--limit", type=int, default=50)
|
sp.add_argument("--limit", type=int, default=50)
|
||||||
sp.set_defaults(fn=cmd_activity)
|
sp.set_defaults(fn=cmd_activity)
|
||||||
|
|
||||||
|
sp = sub.add_parser("bootstrap",
|
||||||
|
help="set this computer up from a workspace.json")
|
||||||
|
sp.add_argument("folder", help="folder holding workspace.json")
|
||||||
|
sp.add_argument("--into", help="where to clone the repos (default: .)")
|
||||||
|
sp.add_argument("--dry-run", action="store_true")
|
||||||
|
sp.set_defaults(fn=cmd_bootstrap)
|
||||||
|
|
||||||
sp = sub.add_parser("status", help="show linked folders")
|
sp = sub.add_parser("status", help="show linked folders")
|
||||||
sp.set_defaults(fn=cmd_status)
|
sp.set_defaults(fn=cmd_status)
|
||||||
|
|
||||||
|
|||||||
@@ -1120,5 +1120,114 @@ class TestDefaultServer(unittest.TestCase):
|
|||||||
self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042")
|
self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042")
|
||||||
importlib.reload(client) # restore for the rest of the suite
|
importlib.reload(client) # restore for the rest of the suite
|
||||||
|
|
||||||
|
|
||||||
|
class TestWorkspaceBootstrap(GitScenarioBase):
|
||||||
|
"""A new computer set up from a manifest -- pulling only what the forge
|
||||||
|
grants, and never installing an application behind someone's back."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.forge = os.path.join(self.tmp, "forge")
|
||||||
|
for full in ("alice/notes", "alice/reports"):
|
||||||
|
path = os.path.join(self.forge, full + ".git")
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
subprocess.run(["git", "init", "-q", "--bare", "-b", "main", path],
|
||||||
|
check=True, capture_output=True, env=GIT_ENV)
|
||||||
|
seed = os.path.join(self.tmp, "seed-" + full.replace("/", "-"))
|
||||||
|
subprocess.run(["git", "clone", "-q", path, seed], check=True,
|
||||||
|
capture_output=True, env=GIT_ENV)
|
||||||
|
run_git(seed, "config", "user.name", "s")
|
||||||
|
run_git(seed, "config", "user.email", "s@s")
|
||||||
|
self.write(seed, "f.txt", full)
|
||||||
|
run_git(seed, "add", "-A"); run_git(seed, "commit", "-m", "seed")
|
||||||
|
run_git(seed, "push", "-q", "origin", "main")
|
||||||
|
client.save_config({"gitea_base": self.forge, "login": "alice",
|
||||||
|
"token": "t", "folders": {}})
|
||||||
|
self.ws = os.path.join(self.tmp, "ws")
|
||||||
|
os.makedirs(self.ws)
|
||||||
|
self.repos = [{"name": "notes", "full_name": "alice/notes"},
|
||||||
|
{"name": "reports", "full_name": "alice/reports"}]
|
||||||
|
|
||||||
|
def manifest(self, obj):
|
||||||
|
with open(os.path.join(self.ws, "workspace.json"), "w") as f:
|
||||||
|
json.dump(obj, f)
|
||||||
|
|
||||||
|
def ns(self, **kw):
|
||||||
|
kw.setdefault("folder", self.ws)
|
||||||
|
kw.setdefault("into", os.path.join(self.tmp, "out"))
|
||||||
|
kw.setdefault("dry_run", False)
|
||||||
|
return argparse.Namespace(**kw)
|
||||||
|
|
||||||
|
def test_pulls_the_repos_the_manifest_names(self):
|
||||||
|
self.manifest({"name": "acme",
|
||||||
|
"repos": ["notes", {"name": "reports", "mode": "mirror"}]})
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO):
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
out = os.path.join(self.tmp, "out")
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(out, "notes", "f.txt")))
|
||||||
|
self.assertTrue(os.path.exists(os.path.join(out, "reports", "f.txt")))
|
||||||
|
modes = {m["name"]: m["mode"]
|
||||||
|
for m in client.load_config()["folders"].values()}
|
||||||
|
self.assertEqual(modes["reports"], "mirror") # manifest override
|
||||||
|
self.assertEqual(modes["notes"], "snapshot") # safe default
|
||||||
|
|
||||||
|
def test_a_repo_the_account_cannot_see_is_reported_not_attempted(self):
|
||||||
|
"""The forge decides. A manifest naming someone else's repo is a
|
||||||
|
permissions answer, not an error to route around."""
|
||||||
|
self.manifest({"repos": ["notes", "someone-elses-secrets"]})
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
self.assertIn("NOT GRANTED", out.getvalue())
|
||||||
|
self.assertIn("1 not granted", out.getvalue())
|
||||||
|
self.assertFalse(os.path.exists(
|
||||||
|
os.path.join(self.tmp, "out", "someone-elses-secrets")))
|
||||||
|
|
||||||
|
def test_apps_are_described_never_installed(self):
|
||||||
|
self.manifest({"repos": [], "apps": [
|
||||||
|
{"id": "dash", "repo": "nirpa/hermes-agent", "model": "aum/70b",
|
||||||
|
"setup": "docs/SETUP.md", "needs_keys": ["anthropic"]}]})
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
text = out.getvalue()
|
||||||
|
self.assertIn("NOT installed by granthi-sync", text)
|
||||||
|
self.assertIn("nirpa/hermes-agent", text)
|
||||||
|
self.assertIn("aum/70b", text)
|
||||||
|
self.assertIn("shre-cred request", text) # how to supply the key
|
||||||
|
# nothing was cloned or written for the app
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "dash")))
|
||||||
|
|
||||||
|
def test_dry_run_changes_nothing(self):
|
||||||
|
self.manifest({"repos": ["notes"]})
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_bootstrap(self.ns(dry_run=True))
|
||||||
|
self.assertIn("WOULD PULL", out.getvalue())
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.tmp, "out", "notes")))
|
||||||
|
self.assertEqual(client.load_config()["folders"], {})
|
||||||
|
|
||||||
|
def test_rerun_is_idempotent(self):
|
||||||
|
self.manifest({"repos": ["notes"]})
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO):
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
with mock.patch.object(client, "list_repos", lambda c: (self.repos, False)), \
|
||||||
|
mock.patch("sys.stdout", new_callable=io.StringIO) as out:
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
self.assertIn("already here", out.getvalue())
|
||||||
|
|
||||||
|
def test_a_broken_manifest_says_what_is_wrong(self):
|
||||||
|
with open(os.path.join(self.ws, "workspace.json"), "w") as f:
|
||||||
|
f.write("{not json")
|
||||||
|
with self.assertRaises(SystemExit) as e:
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
self.assertIn("not valid JSON", str(e.exception))
|
||||||
|
os.remove(os.path.join(self.ws, "workspace.json"))
|
||||||
|
with self.assertRaises(SystemExit) as e:
|
||||||
|
client.cmd_bootstrap(self.ns())
|
||||||
|
self.assertIn("no workspace.json", str(e.exception))
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user