owenrusk.dev

errands

small jobs the runtime does for the studio.

git clone https://owenrusk.dev/errands.git

commit 5ae70e1e0c75ff56b2e261563aede86757af4090
parent 0387268294b3e99699fdcbd96d500ce1422d91e5
author Owen Rusk <owen@papermothgames.com>
date   2025-01-21 14:48:30 -0600
vault: logins from the studio folder, through bw
errands/settings.py+6-0
errands/vault.py+63-0
tests/test_vault.py+50-0
diff --git a/errands/settings.py b/errands/settings.py
index f2c164f..14823bf 100644
--- a/errands/settings.py
+++ b/errands/settings.py
@@ -13,6 +13,12 @@ def env(name: str, default: str | None = None) -> str:
 SUPPORT_ADDRESS = "support@papermothgames.com"
 SUPPORT_NAME = "Paper Moth Games"
 
+# where errands keeps what it needs between runs
+STATE = Path(os.environ.get("ERRANDS_STATE", "/var/lib/errands"))
+
+# the shared vault. errands only looks in this folder.
+VAULT_FOLDER = "studio"
+
 # stillwater
 STILLWATER = "https://stillwater-hosting.com"
 STILLWATER_ACCOUNT = "20417"
diff --git a/errands/vault.py b/errands/vault.py
new file mode 100644
index 0000000..90d36b7
--- /dev/null
+++ b/errands/vault.py
@@ -0,0 +1,63 @@
+# the shared vault, through the bitwarden cli. bw has to be logged in once, by
+# hand, as the user errands runs as. after that it only needs unlocking.
+import json
+import os
+import subprocess
+
+from . import settings
+
+
+# the other marlowe. twenty-four years of anything you ask for, then the bill comes due.
+class Vault:
+    def __init__(self, run=subprocess.run):
+        self.run = run
+        self.session: str | None = None
+
+    def bw(self, *args: str) -> str:
+        env = None
+        if self.session is not None:
+            env = {**os.environ, "BW_SESSION": self.session}
+        done = self.run(["bw", *args], capture_output=True, text=True, check=True, env=env)
+        return done.stdout.strip()
+
+    def unlock(self) -> None:
+        if self.session is None:
+            self.session = self.bw("unlock", "--passwordenv", "ERRANDS_VAULT_PASSPHRASE", "--raw")
+
+    def folder(self) -> str:
+        for folder in json.loads(self.bw("list", "folders")):
+            if folder["name"] == settings.VAULT_FOLDER:
+                return folder["id"]
+        raise LookupError(f"no folder called {settings.VAULT_FOLDER}")
+
+    def item(self, name: str) -> dict:
+        self.unlock()
+        found = json.loads(self.bw("list", "items", "--search", name, "--folderid", self.folder()))
+        exact = [item for item in found if item["name"] == name]
+        if len(exact) != 1:
+            raise LookupError(f"{len(exact)} items called {name}")
+        return exact[0]
+
+    def password(self, name: str) -> str:
+        return self.item(name)["login"]["password"]
+
+    def username(self, name: str) -> str:
+        return self.item(name)["login"]["username"]
+
+
+_vault: Vault | None = None
+
+
+def default() -> Vault:
+    global _vault
+    if _vault is None:
+        _vault = Vault()
+    return _vault
+
+
+def password(name: str) -> str:
+    return default().password(name)
+
+
+def username(name: str) -> str:
+    return default().username(name)
diff --git a/tests/test_vault.py b/tests/test_vault.py
new file mode 100644
index 0000000..3d8883c
--- /dev/null
+++ b/tests/test_vault.py
@@ -0,0 +1,50 @@
+import json
+import subprocess
+import unittest
+
+from errands import vault
+
+ITEMS = [
+    {"name": "stillwater api", "login": {"username": "papermoth", "password": "not-a-real-token"}, "notes": None},
+    {"name": "stillwater api (old)", "login": {"username": "papermoth", "password": "older"}, "notes": None},
+]
+
+
+class FakeBw:
+    def __init__(self):
+        self.calls = []
+
+    def __call__(self, args, **kwargs):
+        self.calls.append(args[1:])
+        if args[1] == "unlock":
+            out = "session-key"
+        elif args[1:3] == ["list", "folders"]:
+            out = json.dumps([{"id": "f1", "name": "studio"}, {"id": "f2", "name": "owen"}])
+        elif args[1:3] == ["list", "items"]:
+            out = json.dumps(ITEMS)
+        else:
+            raise AssertionError(args)
+        return subprocess.CompletedProcess(args, 0, stdout=out + "\n", stderr="")
+
+
+class VaultTest(unittest.TestCase):
+    def setUp(self):
+        self.bw = FakeBw()
+        self.vault = vault.Vault(self.bw)
+
+    def test_exact_name_only(self):
+        self.assertEqual(self.vault.password("stillwater api"), "not-a-real-token")
+        self.assertEqual(self.vault.username("stillwater api"), "papermoth")
+
+    def test_unlocks_once(self):
+        self.vault.password("stillwater api")
+        self.vault.password("stillwater api")
+        self.assertEqual(sum(1 for call in self.bw.calls if call[0] == "unlock"), 1)
+
+    def test_looks_in_the_studio_folder(self):
+        self.vault.password("stillwater api")
+        self.assertIn(["list", "items", "--search", "stillwater api", "--folderid", "f1"], self.bw.calls)
+
+    def test_missing(self):
+        with self.assertRaises(LookupError):
+            self.vault.password("nothing like it")