doploy-cli 0.2.3__tar.gz → 0.2.6__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doploy-cli
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://doploy.io
@@ -20,8 +20,14 @@ import webbrowser
20
20
  import stat
21
21
  import sys
22
22
  import time
23
+ from contextlib import contextmanager
23
24
  from pathlib import Path
24
25
 
26
+ try:
27
+ import fcntl
28
+ except ImportError: # Windows: no cross-process lock, refresh as before
29
+ fcntl = None
30
+
25
31
  from typing import Optional
26
32
 
27
33
  import httpx
@@ -39,9 +45,34 @@ def _load_config() -> dict:
39
45
 
40
46
 
41
47
  def _save_config(cfg: dict) -> None:
48
+ # Write-then-rename so a concurrent reader never sees a half-written file
49
+ # (it would parse as {} and report "not logged in").
50
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
51
+ tmp = CONFIG_PATH.with_name(f'{CONFIG_PATH.name}.{os.getpid()}.tmp')
52
+ tmp.unlink(missing_ok=True) # leftover from a crashed run
53
+ # Created 0600 (token inside) and O_EXCL: never readable by other users,
54
+ # never follows a planted symlink.
55
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
56
+ with os.fdopen(fd, 'w') as fh:
57
+ fh.write(json.dumps(cfg, indent=2) + '\n')
58
+ os.replace(tmp, CONFIG_PATH)
59
+
60
+
61
+ @contextmanager
62
+ def _refresh_lock():
63
+ """Serialize token refresh across doploy processes. The server rotates the
64
+ refresh token on every use and revokes the whole grant if an already-used
65
+ one is presented again, so two processes refreshing at once log you out."""
66
+ if fcntl is None:
67
+ yield
68
+ return
42
69
  CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
43
- CONFIG_PATH.write_text(json.dumps(cfg, indent=2) + '\n')
44
- CONFIG_PATH.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0600: token inside
70
+ with open(CONFIG_PATH.with_name(CONFIG_PATH.name + '.lock'), 'w') as fh:
71
+ fcntl.flock(fh, fcntl.LOCK_EX)
72
+ try:
73
+ yield
74
+ finally:
75
+ fcntl.flock(fh, fcntl.LOCK_UN)
45
76
 
46
77
 
47
78
  class Client:
@@ -56,21 +87,30 @@ class Client:
56
87
  def _refresh_access_token(self) -> bool:
57
88
  if not self.refresh or not self.refresh.get('refresh_token'):
58
89
  return False
59
- r = _oauth_token_request(self.base, {
60
- 'grant_type': 'refresh_token',
61
- 'refresh_token': self.refresh['refresh_token'],
62
- 'client_id': self.refresh['client_id'],
63
- })
64
- if r is None:
65
- return False
66
- self.token = r['access_token']
67
- self.refresh['refresh_token'] = r['refresh_token']
68
- self.refresh['expires_at'] = time.time() + int(r.get('expires_in') or 3600)
69
- cfg = _load_config()
70
- cfg.update(access_token=self.token, refresh_token=self.refresh['refresh_token'],
71
- expires_at=self.refresh['expires_at'])
72
- _save_config(cfg)
73
- return True
90
+ with _refresh_lock():
91
+ # Another process may have refreshed while we waited for the lock:
92
+ # use its tokens instead of replaying our now-rotated refresh token.
93
+ cfg = _load_config()
94
+ if cfg.get('refresh_token') and cfg['refresh_token'] != self.refresh['refresh_token']:
95
+ self.token = cfg.get('access_token') or ''
96
+ self.refresh['refresh_token'] = cfg['refresh_token']
97
+ self.refresh['expires_at'] = cfg.get('expires_at', 0)
98
+ return True
99
+ r = _oauth_token_request(self.base, {
100
+ 'grant_type': 'refresh_token',
101
+ 'refresh_token': self.refresh['refresh_token'],
102
+ 'client_id': self.refresh['client_id'],
103
+ })
104
+ if r is None:
105
+ return False
106
+ self.token = r['access_token']
107
+ self.refresh['refresh_token'] = r['refresh_token']
108
+ self.refresh['expires_at'] = time.time() + int(r.get('expires_in') or 3600)
109
+ cfg = _load_config()
110
+ cfg.update(access_token=self.token, refresh_token=self.refresh['refresh_token'],
111
+ expires_at=self.refresh['expires_at'])
112
+ _save_config(cfg)
113
+ return True
74
114
 
75
115
  def request(self, method: str, path: str, **kw):
76
116
  if self.refresh and self.refresh.get('expires_at', 0) < time.time() + 30:
@@ -143,8 +183,12 @@ def _free_port() -> int:
143
183
  return s.getsockname()[1]
144
184
 
145
185
 
146
- def _wait_for_callback(port: int, state: str, timeout: float = 300.0) -> str:
147
- """Serve one loopback request and return the OAuth `code` it carried."""
186
+ def _wait_for_callback(port: int, state: str, done_url: str, timeout: float = 300.0) -> str:
187
+ """Serve one loopback request and return the OAuth `code` it carried.
188
+
189
+ The browser is sent on to ``done_url?status=...`` (a branded page hosted by
190
+ the doploy server) rather than shown bare text from this loopback server.
191
+ """
148
192
  from urllib.parse import parse_qs, urlparse
149
193
  result: dict = {}
150
194
 
@@ -152,15 +196,15 @@ def _wait_for_callback(port: int, state: str, timeout: float = 300.0) -> str:
152
196
  def do_GET(self): # noqa: N802
153
197
  q = parse_qs(urlparse(self.path).query)
154
198
  if q.get('state', [''])[0] != state:
155
- body, result['error'] = 'State mismatch. Close this tab and retry.', 'state mismatch'
199
+ status, result['error'] = 'error', 'state mismatch'
156
200
  elif q.get('error'):
157
- body, result['error'] = 'Login cancelled. You can close this tab.', q['error'][0]
201
+ status, result['error'] = 'cancelled', q['error'][0]
158
202
  else:
159
- body, result['code'] = 'Logged in to doploy. You can close this tab.', q.get('code', [''])[0]
160
- self.send_response(200)
161
- self.send_header('Content-Type', 'text/html')
203
+ status, result['code'] = 'ok', q.get('code', [''])[0]
204
+ self.send_response(302)
205
+ self.send_header('Location', f'{done_url}?status={status}')
206
+ self.send_header('Content-Length', '0')
162
207
  self.end_headers()
163
- self.wfile.write(f'<html><body style="font-family:sans-serif"><p>{body}</p></body></html>'.encode())
164
208
 
165
209
  def log_message(self, *a): # silence
166
210
  pass
@@ -201,7 +245,7 @@ def _browser_login(base: str) -> None:
201
245
  print('Opening your browser to sign in to doploy. If it does not open, visit:')
202
246
  print(f' {url}')
203
247
  webbrowser.open(url)
204
- code = _wait_for_callback(port, state)
248
+ code = _wait_for_callback(port, state, f'{issuer}/cli/complete')
205
249
 
206
250
  tok = _oauth_token_request(base, {
207
251
  'grant_type': 'authorization_code', 'code': code, 'redirect_uri': redirect_uri,
@@ -573,7 +617,7 @@ def main(argv=None):
573
617
  help='required when targeting production')
574
618
  sp.set_defaults(func=cmd_restart)
575
619
 
576
- sp = sub.add_parser('logs', help='tail build logs (build, test or odoo source)')
620
+ sp = sub.add_parser('logs', help='tail build logs (build, test, or odoo source)')
577
621
  sp.add_argument('build_id', type=int)
578
622
  sp.add_argument('--source', choices=['build', 'test', 'odoo'], default='build')
579
623
  sp.add_argument('--lines', type=int, default=200, help='tail length (max 2000)')
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: doploy-cli
3
- Version: 0.2.3
3
+ Version: 0.2.6
4
4
  Summary: doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://doploy.io
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "doploy-cli"
7
- version = "0.2.3"
7
+ version = "0.2.6"
8
8
  description = "doploy CLI: watch builds, read logs, and drive Odoo deployments from a terminal"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
File without changes
File without changes
File without changes