doploy-cli 0.2.3__tar.gz → 0.2.5__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.5
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:
@@ -573,7 +613,7 @@ def main(argv=None):
573
613
  help='required when targeting production')
574
614
  sp.set_defaults(func=cmd_restart)
575
615
 
576
- sp = sub.add_parser('logs', help='tail build logs (build, test or odoo source)')
616
+ sp = sub.add_parser('logs', help='tail build logs (build, test, or odoo source)')
577
617
  sp.add_argument('build_id', type=int)
578
618
  sp.add_argument('--source', choices=['build', 'test', 'odoo'], default='build')
579
619
  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.5
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.5"
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