@crvouga/dev-fleet 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +28 -0
  2. package/ansible/macos/manager.yml +3 -0
  3. package/ansible/macos/t3-policy.yml +49 -0
  4. package/ansible/ubuntu/policy.json +1 -0
  5. package/ansible/ubuntu/t3-desktop.yml +19 -0
  6. package/ansible/ubuntu/t3-health.sh +93 -22
  7. package/ansible/ubuntu/t3-idle.py +42 -0
  8. package/ansible/ubuntu/t3-policy.yml +4 -11
  9. package/ansible/ubuntu/tasks/recovery.yml +60 -1
  10. package/ansible/ubuntu/tasks/t3-restart.yml +37 -0
  11. package/ansible/ubuntu/wip-guard.py +121 -0
  12. package/bootstrap/native-t3.ts +22 -11
  13. package/cliproxy/README.md +3 -3
  14. package/cliproxy/scripts/accounts.py +216 -0
  15. package/cliproxy/scripts/checkpoint.py +29 -1
  16. package/cliproxy/scripts/cli.py +1 -1
  17. package/cliproxy/scripts/clientkeys.py +99 -0
  18. package/cliproxy/scripts/lease.py +80 -0
  19. package/cliproxy/scripts/ops +137 -25
  20. package/cliproxy/scripts/restore-verify +15 -0
  21. package/docs/accounts.md +154 -0
  22. package/docs/declared.md +3 -1
  23. package/docs/durability.md +26 -0
  24. package/docs/machine-setup.md +4 -3
  25. package/docs/topology.md +2 -0
  26. package/docs/ubuntu-sync.md +28 -22
  27. package/llms.txt +1 -0
  28. package/mise.toml +3 -1
  29. package/package.json +2 -2
  30. package/schema/fleet.schema.json +66 -0
  31. package/src/accounts/checks.ts +220 -0
  32. package/src/accounts/context.ts +76 -0
  33. package/src/accounts/propagate.ts +62 -0
  34. package/src/accounts/roster.ts +85 -0
  35. package/src/accounts/selection.ts +67 -0
  36. package/src/accounts/sessions.ts +120 -0
  37. package/src/agents/account-relay.ts +176 -26
  38. package/src/agents/launch.ts +84 -5
  39. package/src/agents/native-verify.ts +128 -2
  40. package/src/cli/fleet-accounts.ts +253 -0
  41. package/src/cli/fleet-sync-accounts.ts +58 -0
  42. package/src/cli/fleet-sync-manager.ts +20 -4
  43. package/src/cli/fleet-sync-output.ts +3 -2
  44. package/src/cli/fleet-sync-remote.ts +20 -13
  45. package/src/cli/fleet-sync.ts +36 -0
  46. package/src/cli/register-fleet.ts +35 -0
  47. package/src/cli/registry.ts +13 -0
  48. package/src/definition.ts +85 -3
  49. package/src/ide/definition.ts +86 -0
  50. package/src/ide/ide.ts +43 -0
  51. package/src/ide/index.ts +5 -0
  52. package/src/ide/orchestrate.ts +50 -0
  53. package/src/ide/t3.ts +111 -0
  54. package/src/manager-sync.ts +1 -15
  55. package/src/node-converge-linux.ts +28 -7
  56. package/src/platform/darwin.ts +0 -13
  57. package/src/platform/fallback.ts +0 -8
  58. package/src/platform/linux.ts +0 -10
  59. package/src/platform/t3-policy.ts +33 -6
  60. package/src/platform/t3-service.ts +104 -0
  61. package/src/platform/types.ts +0 -4
  62. package/src/platform/windows.ts +0 -14
  63. package/src/remote-entry.ts +15 -1
  64. package/src/service-auth.ts +15 -5
  65. package/src/t3/accounts.ts +7 -1
  66. package/src/t3/models.ts +133 -0
  67. package/src/t3/pairing.ts +31 -0
  68. package/src/t3/profile.ts +203 -0
  69. package/src/t3/version.ts +119 -0
  70. package/src/t3-linked-check.ts +1 -1
  71. package/src/transport.ts +3 -2
  72. package/src/worker-host-checks.ts +36 -13
@@ -146,7 +146,7 @@ devfleet cliproxy inference responses EXACT_CODEX_MODEL_ID
146
146
  devfleet cliproxy inference messages EXACT_CLAUDE_MODEL_ID
147
147
  ```
148
148
 
149
- Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
149
+ `devfleet fleet-accounts --add|--relogin|--remove` wraps these and propagates the result to every node; a login writes into a staging directory and only a complete credential is moved in. Codex uses the upstream device-code flow. Remote callback flows may require the documented dashboard callback flow or temporary SSH loopback forwarding; callback ports are not exposed by default. This pinned version has no generic Gemini CLI login flag; use its supported dashboard/API-key integration. Provider rules still govern permissible account use.
150
150
 
151
151
  **Account routing.** Credential files are the only list of accounts. `ops declared` (run by every fleet-sync) gives each Claude, Codex and Antigravity credential the model prefix `<type>-<email>` through the proxy's management API (`scripts/accounts.py`; a credential file is never edited directly). A request for `<type>-<email>/<model>` is served only by that account, and `/v1/models` lists those ids, which is how every fleet node discovers the accounts to offer in T3. Nothing in Git names an account.
152
152
 
@@ -167,7 +167,7 @@ python3 cliproxy/clients/bootstrap.py --validate
167
167
 
168
168
  Enter the inference key at the hidden prompt. It is available in Vault `secret/personal/prd:CLIPROXY_FLEET_CLIENT_API_KEY`; dashboard login uses `CLIPROXY_FLEET_MANAGEMENT_KEY`. Secrets never appear in command arguments. Keys are saved mode 0600 and loaded only into child environments. Give runners separate revocable inference keys where needed, never provider OAuth credentials or the admin key.
169
169
 
170
- Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved. Use plain native clients during a proxy outage.
170
+ Codex uses custom-provider CLI overrides and Responses HTTP/SSE. Claude uses its supported base URL/auth-token variables and an explicit Claude model. OpenCode uses its config-file override, with separate Responses and Anthropic SDK providers. Once Claude is authenticated, rerun with `--claude-model MODEL_ID`; otherwise the Claude launcher fails with an explicit setup requirement. Native client config files are preserved. Fleet nodes never fall back to native clients: provider accounts exist only on the proxy (see [docs/accounts.md](../docs/accounts.md)), and during a proxy outage every relayed launcher retries for about a minute, then reports "proxy unreachable".
171
171
 
172
172
  Run bootstrap from a repository checkout containing `clients/launcher.py`; detached downloaded bootstrap files are unsupported. Bootstrap is idempotent and refuses unrelated launcher collisions. `--uninstall` removes launcher links while retaining private settings for deliberate cleanup. On Windows run inside WSL; native PowerShell launchers are not provided. Validate from each actual machine and target repository because OpenCode merges project settings.
173
173
 
@@ -187,7 +187,7 @@ Vault login is interactive with hidden input; its token is restricted locally an
187
187
 
188
188
  Encrypted cloud checkpoints run every 15 minutes (legacy systemd adds up to 30 seconds jitter). A checkpoint never stops, pauses or restarts the stack: agents use the proxy around the clock, and a stopped connector fails every request. The capture copies the live state and keeps the copy only if no state file changed while it was taken (retrying a few times); a stack that never holds still fails that backup. A failed capture, restart, upload or retention operation records failure and never advances the success marker. Retention keeps the latest 96 snapshots plus 7 daily, 5 weekly and 12 monthly. Weekly integrity checks read 10% of stored data; `check-full` reads all data. Monitoring fails when the last successful checkpoint is older than 45 minutes. Failures are visible in `cliproxy status` (and `launchctl print gui/$UID/LABEL` or systemd); off-machine checks also run through the CLI proxy health workflow described below.
189
189
 
190
- R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
190
+ R2 checkpoints are the durable recovery source for config, provider OAuth/account state and the dedicated Tailscale identity. Local state is a working cache with asynchronous durability: normally up to approximately 15 minutes plus upload duration can be lost, and an outage can extend that indefinitely. Run a successful manual checkpoint after important login/config changes or before moving machines. Provider refresh-token invalidation can still require a fresh provider login after recovering an older checkpoint. Each checkpoint records a digest of every refresh token, so `restore-verify RESTORED --newest NEWEST` names exactly the accounts that need one. This is a single-writer deployment, with operator fencing of the previous node; there is no distributed lease or active-active support.
191
191
 
192
192
  Checkpoints include only allowlisted runtime state, endpoint intent, repository identity and source revision metadata. GitHub supplies code; Vault supplies client/admin keys, the encryption password and current cloud credentials. Vault/tunnel tokens, local secret files, logs, prior config copies, status files and executable source are excluded. Config and OAuth files themselves contain secrets and remain encrypted in R2. Local capture/restore directories are private plaintext caches, deleted after normal completion. Abandoned generated `checkpoint-*` and `restore-*` directories under `recovery/` expire after 24 hours. Cleanup runs under the operation lock before each backup operation, so active local captures/restores cannot be collected. Unknown/manual rollback directories are preserved. Restic runs without a persistent local metadata cache.
193
193
 
@@ -36,3 +36,219 @@ def pending(auths):
36
36
  if wanted and data.get('prefix') != wanted:
37
37
  result.append((path.name, wanted))
38
38
  return result
39
+
40
+
41
+ # ── Account state ────────────────────────────────────────────────────────────
42
+ #
43
+ # One state per credential, from its file (never a token, only whether one is
44
+ # there and when the access token expires) and from the proxy's management
45
+ # API, which knows about refreshes, cooldowns and disabled credentials.
46
+
47
+ STATES = ('usable', 'cooling-down', 'needs-login', 'refresh-failing', 'removed')
48
+
49
+ # A refresh the provider refused for good: only a new sign-in helps.
50
+ REJECTED = re.compile(r'invalid_grant|invalid_token|unauthorized|revoked|refresh token (?:is )?(?:invalid|expired|rejected|reused)', re.I)
51
+
52
+
53
+ def _instant(value):
54
+ """Seconds since the epoch from an ISO 8601 string or a number, or None."""
55
+ import datetime
56
+ if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0:
57
+ return float(value) / (1000 if value > 1e11 else 1)
58
+ if isinstance(value, str) and value and not value.startswith('0001-'):
59
+ try:
60
+ parsed = datetime.datetime.fromisoformat(value.replace('Z', '+00:00'))
61
+ except ValueError:
62
+ return None
63
+ if parsed.tzinfo is None:
64
+ parsed = parsed.replace(tzinfo=datetime.timezone.utc)
65
+ return parsed.timestamp()
66
+ return None
67
+
68
+
69
+ def iso(seconds):
70
+ import datetime
71
+ return datetime.datetime.fromtimestamp(seconds, datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
72
+
73
+
74
+ def classify(credential, entry, now):
75
+ """(state, until): `credential` is the auth file, `entry` what the
76
+ management API says about it (None when it does not list it). `until` is
77
+ when a cooldown clears, as epoch seconds, or None."""
78
+ entry = entry or {}
79
+ if entry.get('disabled') is True or credential.get('disabled') is True:
80
+ return 'removed', None
81
+ message = str(entry.get('status_message') or entry.get('last_error') or '')
82
+ expires = _instant(credential.get('expired') or credential.get('expiry'))
83
+ expired = expires is not None and expires <= now
84
+ has_refresh = bool(credential.get('refresh_token'))
85
+ if REJECTED.search(message) or (expired and not has_refresh):
86
+ return 'needs-login', None
87
+ if entry.get('status') == 'error' and 'refresh' in message.lower():
88
+ return ('needs-login' if expired else 'refresh-failing'), None
89
+ quota = entry.get('quota') if isinstance(entry.get('quota'), dict) else {}
90
+ until = max(
91
+ [t for t in (_instant(entry.get('next_retry_after')),
92
+ _instant(quota.get('next_recover_at'))) if t is not None and t > now]
93
+ or [0]
94
+ )
95
+ if until or entry.get('unavailable') is True or quota.get('exceeded') is True:
96
+ return 'cooling-down', (until or None)
97
+ return 'usable', None
98
+
99
+
100
+ def credentials(auths):
101
+ """[(file name, credential)] for every readable credential, in a stable
102
+ order: by provider, then sign-in email, then file name. Each provider's
103
+ accounts are numbered in this order, the same order every node lists
104
+ them in (src/agents/accounts.ts), so an index means one account
105
+ everywhere."""
106
+ found = []
107
+ for path in pathlib.Path(auths).glob('*.json'):
108
+ try:
109
+ data = json.loads(path.read_text())
110
+ except (OSError, ValueError):
111
+ continue
112
+ if isinstance(data, dict) and isinstance(data.get('type'), str):
113
+ found.append((path.name, data))
114
+ email = lambda data: data.get('email') if isinstance(data.get('email'), str) else ''
115
+ return sorted(found, key=lambda item: (item[1]['type'], email(item[1]), item[0]))
116
+
117
+
118
+ def roster(auths, entries=(), now=None):
119
+ """Every provider account: provider, opaque index (1, 2, … per provider),
120
+ label, state and, while cooling down, when it clears. `entries` is the
121
+ management API's list of auth files. Never a token."""
122
+ import time
123
+ now = time.time() if now is None else now
124
+ by_name = {e.get('name'): e for e in entries if isinstance(e, dict)}
125
+ counters = {}
126
+ result = []
127
+ for name, credential in credentials(auths):
128
+ provider = credential['type']
129
+ counters[provider] = counters.get(provider, 0) + 1
130
+ state, until = classify(credential, by_name.get(name), now)
131
+ email = credential.get('email')
132
+ result.append({
133
+ 'provider': provider,
134
+ 'index': counters[provider],
135
+ 'label': email if isinstance(email, str) else provider + '-' + str(counters[provider]),
136
+ 'prefix': prefix(provider, email),
137
+ 'state': state,
138
+ 'until': iso(until) if until else None,
139
+ 'file': name,
140
+ })
141
+ return result
142
+
143
+
144
+ def version(accounts):
145
+ """The roster version: a digest of the non-secret metadata of the accounts
146
+ every node offers (their routing prefixes). Every node computes the same
147
+ digest from the proxy's model list (src/accounts/roster.ts)."""
148
+ import hashlib
149
+ prefixes = sorted({a['prefix'] for a in accounts if a.get('prefix') and a.get('state') != 'removed'})
150
+ return hashlib.sha256('\n'.join(prefixes).encode()).hexdigest()[:16]
151
+
152
+
153
+ def public(accounts):
154
+ """The roster as reported: no file name."""
155
+ return [{k: v for k, v in a.items() if k != 'file'} for a in accounts]
156
+
157
+
158
+ def find(accounts, provider, index):
159
+ """The one account `provider` `index` names, or None."""
160
+ for account in accounts:
161
+ if account['provider'] == provider and account['index'] == index:
162
+ return account
163
+ return None
164
+
165
+
166
+ # ── Sign-in, re-login and removal ────────────────────────────────────────────
167
+ #
168
+ # A login writes into a staging directory, never into the auth directory. Only
169
+ # a complete credential (readable JSON with a type) is moved in, with one
170
+ # rename, so an interrupted login leaves no half-written auth file.
171
+
172
+ def staging(auths):
173
+ return pathlib.Path(auths).parent / 'auths-staging'
174
+
175
+
176
+ def begin(auths):
177
+ """A fresh, empty staging directory; leftovers of an interrupted login are
178
+ discarded."""
179
+ import shutil
180
+ stage = staging(auths)
181
+ shutil.rmtree(stage, ignore_errors=True)
182
+ stage.mkdir(mode=0o700, parents=True)
183
+ return stage
184
+
185
+
186
+ def adopt(stage, auths, replace=None):
187
+ """Moves every complete credential from `stage` into `auths` and returns
188
+ their file names. `replace` (a file name) is the credential a re-login
189
+ signs in again: it is replaced by the new one in the same rename. The
190
+ staging directory is removed whatever happens."""
191
+ import os
192
+ import shutil
193
+ stage, auths = pathlib.Path(stage), pathlib.Path(auths)
194
+ adopted = []
195
+ try:
196
+ complete = []
197
+ for path in sorted(stage.glob('*.json')):
198
+ try:
199
+ data = json.loads(path.read_text())
200
+ except (OSError, ValueError):
201
+ continue
202
+ if isinstance(data, dict) and isinstance(data.get('type'), str):
203
+ complete.append(path)
204
+ if replace and len(complete) != 1:
205
+ raise ValueError('A re-login must produce exactly one credential')
206
+ for path in complete:
207
+ target = auths / (replace or path.name)
208
+ os.chmod(path, 0o600)
209
+ os.replace(path, target)
210
+ adopted.append(target.name)
211
+ if replace and adopted and adopted[0] != replace:
212
+ (auths / replace).unlink(missing_ok=True)
213
+ return adopted
214
+ finally:
215
+ shutil.rmtree(stage, ignore_errors=True)
216
+
217
+
218
+ def remove(auths, provider, index):
219
+ """Deletes the credential of one account; returns its file name."""
220
+ account = find(roster(auths), provider, index)
221
+ if account is None:
222
+ raise ValueError('No such account')
223
+ (pathlib.Path(auths) / account['file']).unlink()
224
+ return account['file']
225
+
226
+
227
+ # ── Refresh tokens across checkpoints ────────────────────────────────────────
228
+ #
229
+ # Providers rotate refresh tokens. Each checkpoint records a short digest of
230
+ # every credential's refresh token (never the token). After a restore, a
231
+ # credential whose digest differs from the newest checkpoint's carries a
232
+ # token the provider has already replaced: that account needs a new login.
233
+
234
+ def token_digests(auths):
235
+ import hashlib
236
+ digests = {}
237
+ for name, credential in credentials(auths):
238
+ token = credential.get('refresh_token')
239
+ if isinstance(token, str) and token:
240
+ digests[name] = hashlib.sha256(token.encode()).hexdigest()[:16]
241
+ return digests
242
+
243
+
244
+ def stale_after_restore(auths, newest):
245
+ """[(provider, index)] of the restored accounts whose refresh token is
246
+ older than the one `newest` (the token digests of the newest checkpoint)
247
+ records."""
248
+ current = token_digests(auths)
249
+ stale = []
250
+ for account in roster(auths):
251
+ name = account['file']
252
+ if name in newest and name in current and current[name] != newest[name]:
253
+ stale.append((account['provider'], account['index']))
254
+ return stale
@@ -127,11 +127,39 @@ def copy_state(root, target):
127
127
  shutil.copyfile(source, destination)
128
128
  shutil.copyfile(root / "INSTALLATION.json", target / "INSTALLATION.json")
129
129
  (target / "CHECKPOINT.json").write_text(
130
- json.dumps({"version": 1, "files": hashes(target)}) + "\n"
130
+ json.dumps(
131
+ {
132
+ "version": 1,
133
+ "files": hashes(target),
134
+ "tokens": _accounts().token_digests(target / "state/runtime/auths"),
135
+ }
136
+ )
137
+ + "\n"
131
138
  )
132
139
  validate(target)
133
140
 
134
141
 
142
+ def _accounts():
143
+ """accounts.py beside this file, however this module was loaded."""
144
+ import importlib.util
145
+
146
+ spec = importlib.util.spec_from_file_location(
147
+ "accounts", pathlib.Path(__file__).resolve().parent / "accounts.py"
148
+ )
149
+ module = importlib.util.module_from_spec(spec)
150
+ spec.loader.exec_module(module)
151
+ return module
152
+
153
+
154
+ def stale_accounts(restored, newest):
155
+ """[(provider, index)] of the accounts in the restored checkpoint
156
+ `restored` whose refresh token the provider has since replaced, judged
157
+ against the manifest of the newest checkpoint `newest`. Restoring the
158
+ newest checkpoint itself finds none."""
159
+ tokens = json.loads((newest / "CHECKPOINT.json").read_text()).get("tokens") or {}
160
+ return _accounts().stale_after_restore(restored / "state/runtime/auths", tokens)
161
+
162
+
135
163
  def capture(root, target, attempts=6, sleep=time.sleep):
136
164
  """Copies the state of the running stack without ever stopping it: agents
137
165
  use the proxy around the clock, and a stopped connector fails every one of
@@ -21,7 +21,7 @@ root = cliproxy_host.default_root().resolve()
21
21
  args = sys.argv[1:]
22
22
  if not args or args[0] in ["help", "--help", "-h"]:
23
23
  print(
24
- "Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
24
+ "Usage: devfleet cliproxy COMMAND\nCommands: install [--revision REV|--rollback|--dry-run|--prepare-only], audit, public-endpoint [enable|start|disable|verify], status, start, stop, restart, health, models, logs, validate, apply FILE, add-account PROVIDER, relogin PROVIDER INDEX, remove-account PROVIDER INDEX, accounts [--json], client-keys, issue-key|rotate-key|retire-key|revoke-key NODE, inference PROTOCOL MODEL, model-health [--profile liveness|readiness|light|nightly|manual] [--suite core|cursor|all] [--target live|mock], model-suite (same flags; defaults to nightly), backup, gc, snapshots, check, check-full, restore SNAPSHOT EMPTY_DIRECTORY, restore-cache --previous-node-stopped, vault-login, recover, recover-node NEW_DIRECTORY [--vault-token-file FILE], verify-recovery, portable-login, activate, drain"
25
25
  )
26
26
  sys.exit(0)
27
27
  command, *rest = args
@@ -0,0 +1,99 @@
1
+ """One proxy client key per node, rotated with overlap and revoked with the node.
2
+
3
+ The key ring is a private JSON document in the proxy's secrets
4
+ (`state/secrets/client-keys.json`): per node, the current key and, during a
5
+ rotation, the previous one. The proxy accepts every key in the ring
6
+ (`api_keys`), so a rotation adds the new key, every node moves to it, and
7
+ only then is the old key retired: no request fails in between. Revoking a
8
+ node removes its keys and leaves every other node working. Reports name keys
9
+ only by a short fingerprint, never by value.
10
+ """
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import pathlib
15
+ import re
16
+ import secrets
17
+
18
+ NODE = re.compile(r'^[a-z][a-z0-9-]{0,62}$')
19
+
20
+
21
+ def fingerprint(key):
22
+ return hashlib.sha256(key.encode()).hexdigest()[:12]
23
+
24
+
25
+ def load(path):
26
+ try:
27
+ ring = json.loads(pathlib.Path(path).read_text())
28
+ except (OSError, ValueError):
29
+ return {'version': 1, 'nodes': {}}
30
+ if not isinstance(ring, dict) or ring.get('version') != 1:
31
+ raise ValueError('Unsupported client key ring')
32
+ return ring
33
+
34
+
35
+ def save(path, ring):
36
+ path = pathlib.Path(path)
37
+ path.parent.mkdir(parents=True, exist_ok=True)
38
+ staged = path.with_suffix('.new')
39
+ staged.write_text(json.dumps(ring, indent=2, sort_keys=True) + '\n')
40
+ os.chmod(staged, 0o600)
41
+ os.replace(staged, path)
42
+
43
+
44
+ def _node(ring, node):
45
+ if not NODE.match(node):
46
+ raise ValueError('Invalid node id')
47
+ return ring['nodes'].setdefault(node, {'current': None, 'previous': None})
48
+
49
+
50
+ def issue(ring, node, generate=lambda: secrets.token_urlsafe(32)):
51
+ """Gives `node` a key when it has none; returns its current key."""
52
+ entry = _node(ring, node)
53
+ if not entry['current']:
54
+ entry['current'] = generate()
55
+ return entry['current']
56
+
57
+
58
+ def rotate(ring, node, generate=lambda: secrets.token_urlsafe(32)):
59
+ """Starts a rotation: a new current key, the old one kept as previous
60
+ until `retire`. Both are accepted meanwhile."""
61
+ entry = _node(ring, node)
62
+ if entry['previous']:
63
+ raise ValueError('A rotation of this node is already in progress; retire it first')
64
+ entry['previous'], entry['current'] = entry['current'], generate()
65
+ return entry['current']
66
+
67
+
68
+ def retire(ring, node):
69
+ """Ends a rotation once the node uses its new key."""
70
+ _node(ring, node)['previous'] = None
71
+
72
+
73
+ def revoke(ring, node):
74
+ """Removes every key of `node`."""
75
+ ring['nodes'].pop(node, None)
76
+
77
+
78
+ def api_keys(ring, shared=()):
79
+ """Every key the proxy accepts: the ring's, plus `shared` keys still in
80
+ use (the fleet-wide key, until each node has its own)."""
81
+ keys = list(shared)
82
+ for node in sorted(ring['nodes']):
83
+ entry = ring['nodes'][node]
84
+ for key in (entry['current'], entry['previous']):
85
+ if key and key not in keys:
86
+ keys.append(key)
87
+ return keys
88
+
89
+
90
+ def report(ring):
91
+ """Per node: the current key's fingerprint and whether a rotation is
92
+ still waiting to be retired. No key value."""
93
+ return {
94
+ node: {
95
+ 'current': fingerprint(entry['current']) if entry['current'] else None,
96
+ 'rotating': bool(entry['previous']),
97
+ }
98
+ for node, entry in sorted(ring['nodes'].items())
99
+ }
@@ -0,0 +1,80 @@
1
+ """A fencing lease on the proxy state: only its holder is the writer.
2
+
3
+ Providers rotate refresh tokens, so two proxies started from the same
4
+ checkpoint spend each other's tokens. The lease is one small JSON document in
5
+ a store both would see (the checkpoint bucket in production, a directory in
6
+ tests): the holder, a fencing epoch that only ever grows, and when it
7
+ expires. A proxy that cannot take the lease refuses to start as writer.
8
+ """
9
+ import json
10
+ import os
11
+ import pathlib
12
+ import time
13
+
14
+ TTL = 120
15
+
16
+
17
+ class Held(Exception):
18
+ """Another proxy holds the lease."""
19
+
20
+ def __init__(self, holder, expires):
21
+ super().__init__('The proxy state lease is held by another proxy')
22
+ self.holder = holder
23
+ self.expires = expires
24
+
25
+
26
+ class DirectoryStore:
27
+ """A lease document in a directory, replaced with one rename. `put`
28
+ writes only when the document is still the one `get` returned."""
29
+
30
+ def __init__(self, directory):
31
+ self.path = pathlib.Path(directory) / 'proxy-lease.json'
32
+
33
+ def get(self):
34
+ try:
35
+ return json.loads(self.path.read_text())
36
+ except (OSError, ValueError):
37
+ return None
38
+
39
+ def put(self, document, expected):
40
+ import fcntl
41
+ self.path.parent.mkdir(parents=True, exist_ok=True)
42
+ with open(self.path.with_suffix('.lock'), 'w') as lock:
43
+ fcntl.flock(lock, fcntl.LOCK_EX)
44
+ if self.get() != expected:
45
+ return False
46
+ staged = self.path.with_suffix('.new')
47
+ staged.write_text(json.dumps(document) + '\n')
48
+ os.replace(staged, self.path)
49
+ return True
50
+
51
+
52
+ def acquire(store, holder, now=None, ttl=TTL):
53
+ """Takes or renews the lease for `holder`; returns its fencing epoch.
54
+ Raises Held while another holder's lease has not expired. Taking over an
55
+ expired lease increases the epoch, so the old holder's writes are fenced."""
56
+ now = time.time() if now is None else now
57
+ current = store.get()
58
+ if current and current.get('holder') != holder and current.get('expires', 0) > now:
59
+ raise Held(current.get('holder'), current.get('expires'))
60
+ epoch = (current or {}).get('epoch', 0)
61
+ if not current or current.get('holder') != holder:
62
+ epoch += 1
63
+ document = {'holder': holder, 'epoch': epoch, 'expires': now + ttl}
64
+ if not store.put(document, current):
65
+ raise Held(None, None)
66
+ return epoch
67
+
68
+
69
+ def valid(store, holder, epoch, now=None):
70
+ """Whether `holder` still holds the lease at `epoch`: checked before a
71
+ token refresh is written."""
72
+ now = time.time() if now is None else now
73
+ current = store.get() or {}
74
+ return current.get('holder') == holder and current.get('epoch') == epoch and current.get('expires', 0) > now
75
+
76
+
77
+ def release(store, holder):
78
+ current = store.get()
79
+ if current and current.get('holder') == holder:
80
+ store.put({**current, 'expires': 0}, current)