feat(client): workspace bootstrap — what a new computer pulls first
granthi-sync bootstrap <folder> reads a workspace.json and pulls the repos it names, still bounded by what the forge grants: a manifest naming a repo this account cannot see prints NOT GRANTED and continues, because that is a permissions answer, not an error to route around. It does NOT install applications. Dash, deck, genie and shiva each have their own repo, deploy path and reviewers; a sync client installing them would create a second unreviewed deploy path beside the real one. So apps are REFERENCED -- the command prints each app's repo, model, setup doc and the vault keys it needs, with the shre-cred line to supply them. Per-repo mode overrides the default, so a documents folder can be declared mirror while real projects stay on the safe snapshot default. 184 tests (was 178).
This commit is contained in:
@@ -1120,5 +1120,114 @@ class TestDefaultServer(unittest.TestCase):
|
||||
self.assertEqual(reloaded.DEFAULT_SERVER, "http://10.0.0.5:3042")
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user