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.
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/PKG-INFO +1 -1
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli/main.py +71 -27
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/PKG-INFO +1 -1
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/pyproject.toml +1 -1
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/LICENSE +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/README.md +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli/SKILL.md +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli/__init__.py +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/SOURCES.txt +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/dependency_links.txt +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/entry_points.txt +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/requires.txt +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/doploy_cli.egg-info/top_level.txt +0 -0
- {doploy_cli-0.2.3 → doploy_cli-0.2.6}/setup.cfg +0 -0
|
@@ -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.
|
|
44
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
199
|
+
status, result['error'] = 'error', 'state mismatch'
|
|
156
200
|
elif q.get('error'):
|
|
157
|
-
|
|
201
|
+
status, result['error'] = 'cancelled', q['error'][0]
|
|
158
202
|
else:
|
|
159
|
-
|
|
160
|
-
self.send_response(
|
|
161
|
-
self.send_header('
|
|
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)')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|