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")