git-buckets 0.3.0__tar.gz → 0.3.1__tar.gz

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 (21) hide show
  1. {git_buckets-0.3.0 → git_buckets-0.3.1}/PKG-INFO +1 -1
  2. {git_buckets-0.3.0 → git_buckets-0.3.1}/pyproject.toml +1 -1
  3. {git_buckets-0.3.0 → git_buckets-0.3.1}/pyproject.toml.orig +1 -1
  4. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/credential.py +3 -2
  5. git_buckets-0.3.1/src/git_buckets/wake.py +213 -0
  6. git_buckets-0.3.0/src/git_buckets/wake.py +0 -127
  7. {git_buckets-0.3.0 → git_buckets-0.3.1}/README.md +0 -0
  8. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/__init__.py +0 -0
  9. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/cli.py +0 -0
  10. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/hosts.py +0 -0
  11. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/keyring/__init__.py +0 -0
  12. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/keyring/backend.py +0 -0
  13. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/keyring/proof.py +0 -0
  14. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/lfs_agent.py +0 -0
  15. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/login.py +0 -0
  16. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/net.py +0 -0
  17. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/oauth.py +0 -0
  18. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/repo.py +0 -0
  19. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/secretstore.py +0 -0
  20. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/session.py +0 -0
  21. {git_buckets-0.3.0 → git_buckets-0.3.1}/src/git_buckets/termio.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: git-buckets
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Git over S3: clone, fetch and push repositories backed by an S3 bucket.
5
5
  Author: Full Duplex Media
6
6
  Author-email: Full Duplex Media <contact@fullduplex.media>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "git-buckets"
3
- version = "0.3.0"
3
+ version = "0.3.1"
4
4
  description = "Git over S3: clone, fetch and push repositories backed by an S3 bucket."
5
5
  authors = [
6
6
  { name = 'Full Duplex Media', email = 'contact@fullduplex.media' }
@@ -3,7 +3,8 @@
3
3
  Deliberately importless beyond the client's own small modules: this runs on every git operation against the
4
4
  deployment. A warm session token is one cached-file read; a live MicroVM behind it is not something the cache
5
5
  can promise, which is what ``wake.probe`` is for -- one cheap authenticated request, silent unless the bucket
6
- turns out to be cold. ``GB_NO_WAKE_PROBE=1`` drops back to the old zero-network warm path.
6
+ turns out to be cold, and at most once per host per minute so a burst of fills costs one probe.
7
+ ``GB_NO_WAKE_PROBE=1`` drops back to the old zero-network warm path.
7
8
  """
8
9
 
9
10
  import sys
@@ -41,7 +42,7 @@ def get(fields: dict[str, str]) -> str | None:
41
42
  token = session.token(domain, entry)
42
43
  # The bucket's own host, not the deployment domain the token was minted against: that is what git is
43
44
  # about to talk to, and the only host the launcher's cold path answers for.
44
- wake.probe(fields.get('host', domain).partition(':')[0], token)
45
+ wake.probe(fields.get('host', domain).partition(':')[0], token, domain=domain)
45
46
  return f'username={_USERNAME}\npassword={token}\n'
46
47
 
47
48
 
@@ -0,0 +1,213 @@
1
+ """The cold-start wake probe: one cheap authenticated GET, run once before ``get`` hands git a credential.
2
+
3
+ Warm is the common case and it has to cost exactly what it costs today: one request, answered fast, nothing
4
+ printed. Cold is the case this exists for -- a bucket with no live instance yet means the request lands on the
5
+ launcher, which holds the connection for up to its own hold (tens of seconds) before it either answers or
6
+ 307s to the same URL with a growing hop count. A plain request with a long timeout would sit there silently
7
+ for that whole stretch, and a user watching a hung ``git fetch`` has no way to tell a cold start from a stall.
8
+
9
+ So the probe is a single request carrying the whole budget, with a timer running alongside it: if the answer
10
+ has not landed by ``warn_after_s`` the timer prints the "waking it up" line and the request keeps going. The
11
+ request is never abandoned and retried -- an abandoned probe is still served, so on a cold bucket it leaves a
12
+ launcher holding the launch lock and the retry starts a second launcher chain for the same wake. Everything
13
+ else -- a network error, a redirect with no ``Location``, the whole deadline elapsing -- is swallowed: this
14
+ fronts for a credential that has to reach git either way, so nothing here may block or fail a clone that
15
+ would otherwise have worked.
16
+
17
+ One 200 also resets the server's idle timer, so a probe is worth suppressing for a short while afterwards:
18
+ ``wake-probes.json`` in the deployment's state dir records ``{host: expires_at}`` and collapses a burst of
19
+ credential fills (an LFS lock/verify/locks/unlock sequence, a submodule walk) into one probe.
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ from base64 import b64encode
25
+ from contextlib import suppress
26
+ from threading import Lock, Timer
27
+ from time import monotonic, time
28
+ from typing import Any
29
+
30
+ from . import hosts, net
31
+
32
+ HEALTH_PATH = '/_gb/health'
33
+ _NO_PROBE_ENV = 'GB_NO_WAKE_PROBE'
34
+ _TTL_ENV = 'GB_WAKE_PROBE_TTL_S'
35
+ _PROBE_MARKER = 'wake-probes.json'
36
+ _TTL_S = 60.0
37
+ _WARN_AFTER_S = 1.0
38
+ _DEADLINE_S = 90.0
39
+ # Mirrors the launcher's own GB_HOP_BUDGET default; the deadline is what actually stops a runaway chain.
40
+ _MAX_HOPS = 8
41
+ _REDIRECT_STATUSES = (301, 302, 303, 307, 308)
42
+ _WAKE_MESSAGE = 'gb: your git-bucket was asleep, waking it up...'
43
+ _ALREADY_AWAKE_MESSAGE = 'gb: ready (bucket was already awake; slow network)'
44
+ # How much of the server's uptime can predate this probe and still count as "it came up for us".
45
+ _UPTIME_SLACK_S = 2.0
46
+ _BOOT_LABELS = {'launch': 'cold launch', 'resume': 'resumed'}
47
+
48
+
49
+ def probe(
50
+ host: str,
51
+ token: str,
52
+ *,
53
+ domain: str | None = None,
54
+ transport: Any = None,
55
+ clock: Any = monotonic,
56
+ warn_after_s: float = _WARN_AFTER_S,
57
+ deadline_s: float = _DEADLINE_S,
58
+ max_hops: int = _MAX_HOPS,
59
+ stderr: Any = None,
60
+ alarm: Any = None,
61
+ ) -> None:
62
+ """Warm ``host``'s bucket with ``token`` before the caller prints a credential back to git.
63
+
64
+ Skippable with ``GB_NO_WAKE_PROBE=1``, and suppressed for ``GB_WAKE_PROBE_TTL_S`` seconds (default 60,
65
+ 0 disables suppression) after a 200 for the same host under ``domain``. Every exception is caught here,
66
+ not just the network ones: a bug in this module must never turn into a broken ``git fetch``. ``transport``,
67
+ ``stderr`` and ``alarm`` all default to ``None`` and are resolved inside the call rather than bound as
68
+ defaults, so a caller that patches ``net.request_raw`` or swaps ``sys.stderr`` (as the test suite does with
69
+ capsys) is not shadowed by a reference captured at import time.
70
+ """
71
+ if os.environ.get(_NO_PROBE_ENV) == '1':
72
+ return
73
+ ttl_s = _TTL_S
74
+ suppressed = False
75
+ # A state dir that cannot even be named is not a reason to skip the probe: anything raising here means
76
+ # "not suppressed", and the probe runs as if no marker had ever been written.
77
+ with suppress(Exception):
78
+ ttl_s = _ttl_s()
79
+ suppressed = bool(domain) and ttl_s > 0 and _suppressed(str(domain), host)
80
+ if suppressed:
81
+ return
82
+ answered = False
83
+ with suppress(Exception):
84
+ answered = _probe(
85
+ host,
86
+ token,
87
+ transport or net.request_raw,
88
+ clock,
89
+ warn_after_s,
90
+ deadline_s,
91
+ max_hops,
92
+ stderr if stderr is not None else sys.stderr,
93
+ alarm or _timer,
94
+ )
95
+ if answered and domain and ttl_s > 0:
96
+ with suppress(Exception):
97
+ _record(domain, host, ttl_s)
98
+
99
+
100
+ def _probe(
101
+ host: str,
102
+ token: str,
103
+ transport: Any,
104
+ clock: Any,
105
+ warn_after_s: float,
106
+ deadline_s: float,
107
+ max_hops: int,
108
+ stderr: Any,
109
+ alarm: Any,
110
+ ) -> bool:
111
+ start = clock()
112
+ deadline = start + deadline_s
113
+ url = f'https://{host}{HEALTH_PATH}'
114
+ request_headers = {'Authorization': _basic(token)}
115
+ lock = Lock()
116
+ state = {'warned': False, 'done': False}
117
+ status: int | None = None
118
+ response_headers: dict[str, str] = {}
119
+ hops = 0
120
+
121
+ def _warn() -> None:
122
+ # Under the same lock the main thread takes before it announces, so an answer that lands while the
123
+ # timer is already running never gets narrated as if it had been slow.
124
+ with lock:
125
+ if state['done']:
126
+ return
127
+ print(_WAKE_MESSAGE, file=stderr)
128
+ state['warned'] = True
129
+
130
+ countdown = alarm(warn_after_s, _warn)
131
+ countdown.start()
132
+ try:
133
+ while True:
134
+ remaining = deadline - clock()
135
+ if remaining <= 0:
136
+ return False
137
+ try:
138
+ status, response_headers, _ = transport(url, headers=request_headers, timeout=remaining)
139
+ except (net.HttpTimeout, net.HttpError):
140
+ return False
141
+ if status not in _REDIRECT_STATUSES or hops >= max_hops:
142
+ break
143
+ location = response_headers.get('Location') or response_headers.get('location')
144
+ if not location:
145
+ break
146
+ url = location
147
+ hops += 1
148
+ finally:
149
+ countdown.cancel()
150
+ with lock:
151
+ state['done'] = True
152
+
153
+ if status != 200:
154
+ return False
155
+ if state['warned']:
156
+ _announce(clock() - start, response_headers, stderr)
157
+ return True
158
+
159
+
160
+ def _timer(delay: float, callback: Any) -> Timer:
161
+ countdown = Timer(delay, callback)
162
+ countdown.daemon = True
163
+ return countdown
164
+
165
+
166
+ def _announce(elapsed: float, response_headers: dict[str, str], stderr: Any) -> None:
167
+ # A slow DNS lookup or a slow link trips the timer just as well as a cold bucket does, and calling that
168
+ # a wake would be a lie: the server's uptime says whether this process came up during the probe.
169
+ uptime_s = _uptime_s(response_headers)
170
+ if uptime_s is not None and uptime_s > elapsed + _UPTIME_SLACK_S:
171
+ print(_ALREADY_AWAKE_MESSAGE, file=stderr)
172
+ return
173
+ kind = response_headers.get('x-gb-boot') or response_headers.get('X-Gb-Boot') or ''
174
+ label = _BOOT_LABELS.get(kind)
175
+ suffix = f'{label}, {elapsed:.1f}s' if label else f'{elapsed:.1f}s'
176
+ print(f'gb: ready ({suffix})', file=stderr)
177
+
178
+
179
+ def _uptime_s(response_headers: dict[str, str]) -> float | None:
180
+ raw = response_headers.get('x-gb-uptime-s') or response_headers.get('X-Gb-Uptime-S')
181
+ if raw is None:
182
+ return None
183
+ try:
184
+ return float(raw)
185
+ except (TypeError, ValueError):
186
+ return None
187
+
188
+
189
+ def _ttl_s() -> float:
190
+ raw = os.environ.get(_TTL_ENV)
191
+ if raw is None:
192
+ return _TTL_S
193
+ try:
194
+ return float(raw)
195
+ except ValueError:
196
+ return _TTL_S
197
+
198
+
199
+ def _suppressed(domain: str, host: str) -> bool:
200
+ expires_at = (hosts.read_cache(domain, _PROBE_MARKER) or {}).get(host)
201
+ return isinstance(expires_at, int | float) and expires_at > time()
202
+
203
+
204
+ def _record(domain: str, host: str, ttl_s: float) -> None:
205
+ now = time()
206
+ marker = hosts.read_cache(domain, _PROBE_MARKER) or {}
207
+ live = {key: value for key, value in marker.items() if isinstance(value, int | float) and value > now}
208
+ live[host] = now + ttl_s
209
+ hosts.write_cache(domain, _PROBE_MARKER, live)
210
+
211
+
212
+ def _basic(token: str) -> str:
213
+ return 'Basic ' + b64encode(f'gb:{token}'.encode()).decode()
@@ -1,127 +0,0 @@
1
- """The cold-start wake probe: one cheap authenticated GET, run once before ``get`` hands git a credential.
2
-
3
- Warm is the common case and it has to cost exactly what it costs today: one request, answered fast, nothing
4
- printed. Cold is the case this exists for -- a bucket with no live instance yet means the request lands on the
5
- launcher, which holds the connection for up to its own hold (tens of seconds) before it either answers or
6
- 307s to the same URL with a growing hop count. A plain request with a long timeout would sit there silently
7
- for that whole stretch, and a user watching a hung ``git fetch`` has no way to tell a cold start from a stall.
8
-
9
- So the probe leads with a short, cheap attempt (``warn_after_s``) whose only job is to notice slowness. A
10
- timeout there is not a failure -- it is exactly what a cold bucket looks like -- and it is the one signal that
11
- prints the "waking it up" line before switching to the launcher's own long-poll rhythm for the rest of the
12
- budget. Everything else -- a network error, a redirect with no ``Location``, the whole deadline elapsing -- is
13
- swallowed: this fronts for a credential that has to reach git either way, so nothing here may block or fail a
14
- clone that would otherwise have worked.
15
- """
16
-
17
- import os
18
- import sys
19
- from base64 import b64encode
20
- from contextlib import suppress
21
- from time import monotonic
22
- from typing import Any
23
-
24
- from . import net
25
-
26
- HEALTH_PATH = '/_gb/health'
27
- _NO_PROBE_ENV = 'GB_NO_WAKE_PROBE'
28
- _WARN_AFTER_S = 1.0
29
- _DEADLINE_S = 90.0
30
- _MAX_HOPS = 20
31
- _REDIRECT_STATUSES = (301, 302, 303, 307, 308)
32
- _WAKE_MESSAGE = 'gb: your git-bucket was asleep, waking it up...'
33
- _BOOT_LABELS = {'launch': 'cold launch', 'resume': 'resumed'}
34
-
35
-
36
- def probe(
37
- host: str,
38
- token: str,
39
- *,
40
- transport: Any = None,
41
- clock: Any = monotonic,
42
- warn_after_s: float = _WARN_AFTER_S,
43
- deadline_s: float = _DEADLINE_S,
44
- max_hops: int = _MAX_HOPS,
45
- stderr: Any = None,
46
- ) -> None:
47
- """Warm ``host``'s bucket with ``token`` before the caller prints a credential back to git.
48
-
49
- Skippable with ``GB_NO_WAKE_PROBE=1``. Every exception is caught here, not just the network ones: a bug
50
- in this module must never turn into a broken ``git fetch``. ``transport`` and ``stderr`` both default to
51
- ``None`` and are resolved inside the call rather than bound as defaults, so a caller that patches
52
- ``net.request_raw`` or swaps ``sys.stderr`` (as the test suite does with capsys) is not shadowed by a
53
- reference captured at import time.
54
- """
55
- if os.environ.get(_NO_PROBE_ENV) == '1':
56
- return
57
- with suppress(Exception):
58
- _probe(
59
- host,
60
- token,
61
- transport or net.request_raw,
62
- clock,
63
- warn_after_s,
64
- deadline_s,
65
- max_hops,
66
- stderr if stderr is not None else sys.stderr,
67
- )
68
-
69
-
70
- def _probe(
71
- host: str,
72
- token: str,
73
- transport: Any,
74
- clock: Any,
75
- warn_after_s: float,
76
- deadline_s: float,
77
- max_hops: int,
78
- stderr: Any,
79
- ) -> None:
80
- start = clock()
81
- deadline = start + deadline_s
82
- url = f'https://{host}{HEALTH_PATH}'
83
- request_headers = {'Authorization': _basic(token)}
84
- warned = False
85
- first = True
86
- status: int | None = None
87
- response_headers: dict[str, str] = {}
88
- hops = 0
89
-
90
- while True:
91
- remaining = deadline - clock()
92
- if remaining <= 0:
93
- return
94
- timeout = min(warn_after_s, remaining) if first else remaining
95
- try:
96
- status, response_headers, _ = transport(url, headers=request_headers, timeout=timeout)
97
- except net.HttpTimeout:
98
- if not first:
99
- return
100
- print(_WAKE_MESSAGE, file=stderr)
101
- warned = True
102
- first = False
103
- continue
104
- except net.HttpError:
105
- return
106
- first = False
107
- if status not in _REDIRECT_STATUSES or hops >= max_hops:
108
- break
109
- location = response_headers.get('Location') or response_headers.get('location')
110
- if not location:
111
- break
112
- url = location
113
- hops += 1
114
-
115
- if warned and status == 200:
116
- _announce(clock() - start, response_headers, stderr)
117
-
118
-
119
- def _announce(elapsed: float, response_headers: dict[str, str], stderr: Any) -> None:
120
- kind = response_headers.get('x-gb-boot') or response_headers.get('X-Gb-Boot') or ''
121
- label = _BOOT_LABELS.get(kind)
122
- suffix = f'{label}, {elapsed:.1f}s' if label else f'{elapsed:.1f}s'
123
- print(f'gb: ready ({suffix})', file=stderr)
124
-
125
-
126
- def _basic(token: str) -> str:
127
- return 'Basic ' + b64encode(f'gb:{token}'.encode()).decode()
File without changes