ominfra 0.0.0.dev189__py3-none-any.whl → 0.0.0.dev191__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,10 +1,33 @@
1
1
  # ruff: noqa: UP006 UP007
2
2
  import datetime
3
+ import os.path
3
4
  import typing as ta
4
5
 
6
+ from omlish.lite.cached import cached_nullary
7
+ from omlish.lite.check import check
8
+ from omlish.lite.json import json_dumps_pretty
9
+ from omlish.lite.marshal import ObjMarshalerManager
5
10
  from omlish.lite.typing import Func0
11
+ from omlish.lite.typing import Func1
12
+ from omlish.os.paths import abs_real_path
13
+ from omlish.os.paths import relative_symlink
6
14
 
15
+ from .apps import DeployAppManager
16
+ from .conf.manager import DeployConfManager
17
+ from .paths.manager import DeployPathsManager
18
+ from .paths.owners import DeployPathOwner
19
+ from .paths.paths import DeployPath
20
+ from .specs import DeployAppSpec
21
+ from .specs import DeploySpec
22
+ from .systemd import DeploySystemdManager
23
+ from .tags import DeployApp
24
+ from .tags import DeployTagMap
7
25
  from .tags import DeployTime
26
+ from .tmp import DeployHomeAtomics
27
+ from .types import DeployHome
28
+
29
+
30
+ ##
8
31
 
9
32
 
10
33
  DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
@@ -13,17 +36,65 @@ DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
13
36
  DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
14
37
 
15
38
 
16
- class DeployManager:
39
+ class DeployManager(DeployPathOwner):
17
40
  def __init__(
18
41
  self,
19
42
  *,
43
+ atomics: DeployHomeAtomics,
20
44
 
21
45
  utc_clock: ta.Optional[DeployManagerUtcClock] = None,
22
46
  ):
23
47
  super().__init__()
24
48
 
49
+ self._atomics = atomics
50
+
25
51
  self._utc_clock = utc_clock
26
52
 
53
+ #
54
+
55
+ # Home current link just points to CURRENT_DEPLOY_LINK, and is intended for user convenience.
56
+ HOME_CURRENT_LINK = DeployPath.parse('current')
57
+
58
+ DEPLOYS_DIR = DeployPath.parse('deploys/')
59
+
60
+ # Authoritative current symlink is not in deploy-home, just to prevent accidental corruption.
61
+ CURRENT_DEPLOY_LINK = DeployPath.parse(f'{DEPLOYS_DIR}current')
62
+ DEPLOYING_DEPLOY_LINK = DeployPath.parse(f'{DEPLOYS_DIR}deploying')
63
+
64
+ DEPLOY_DIR = DeployPath.parse(f'{DEPLOYS_DIR}@time--@deploy-key/')
65
+ DEPLOY_SPEC_FILE = DeployPath.parse(f'{DEPLOY_DIR}spec.json')
66
+
67
+ APPS_DEPLOY_DIR = DeployPath.parse(f'{DEPLOY_DIR}apps/')
68
+ APP_DEPLOY_LINK = DeployPath.parse(f'{APPS_DEPLOY_DIR}@app')
69
+
70
+ CONFS_DEPLOY_DIR = DeployPath.parse(f'{DEPLOY_DIR}conf/')
71
+ CONF_DEPLOY_DIR = DeployPath.parse(f'{CONFS_DEPLOY_DIR}@conf/')
72
+
73
+ @cached_nullary
74
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
75
+ return {
76
+ self.DEPLOYS_DIR,
77
+
78
+ self.CURRENT_DEPLOY_LINK,
79
+ self.DEPLOYING_DEPLOY_LINK,
80
+
81
+ self.DEPLOY_DIR,
82
+ self.DEPLOY_SPEC_FILE,
83
+
84
+ self.APPS_DEPLOY_DIR,
85
+ self.APP_DEPLOY_LINK,
86
+
87
+ self.CONFS_DEPLOY_DIR,
88
+ self.CONF_DEPLOY_DIR,
89
+ }
90
+
91
+ #
92
+
93
+ def render_path(self, home: DeployHome, pth: DeployPath, tags: ta.Optional[DeployTagMap] = None) -> str:
94
+ return os.path.join(check.non_empty_str(home), pth.render(tags))
95
+
96
+ #
97
+
27
98
  def _utc_now(self) -> datetime.datetime:
28
99
  if self._utc_clock is not None:
29
100
  return self._utc_clock() # noqa
@@ -32,3 +103,225 @@ class DeployManager:
32
103
 
33
104
  def make_deploy_time(self) -> DeployTime:
34
105
  return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
106
+
107
+ #
108
+
109
+ def make_home_current_link(self, home: DeployHome) -> None:
110
+ home_current_link = os.path.join(check.non_empty_str(home), self.HOME_CURRENT_LINK.render())
111
+ current_deploy_link = os.path.join(check.non_empty_str(home), self.CURRENT_DEPLOY_LINK.render())
112
+ with self._atomics(home).begin_atomic_path_swap( # noqa
113
+ 'file',
114
+ home_current_link,
115
+ auto_commit=True,
116
+ ) as dst_swap:
117
+ os.unlink(dst_swap.tmp_path)
118
+ os.symlink(
119
+ os.path.relpath(current_deploy_link, os.path.dirname(dst_swap.dst_path)),
120
+ dst_swap.tmp_path,
121
+ )
122
+
123
+
124
+ ##
125
+
126
+
127
+ class DeployDriverFactory(Func1[DeploySpec, ta.ContextManager['DeployDriver']]):
128
+ pass
129
+
130
+
131
+ class DeployDriver:
132
+ def __init__(
133
+ self,
134
+ *,
135
+ spec: DeploySpec,
136
+ home: DeployHome,
137
+ time: DeployTime,
138
+
139
+ deploys: DeployManager,
140
+ paths: DeployPathsManager,
141
+ apps: DeployAppManager,
142
+ conf: DeployConfManager,
143
+ systemd: DeploySystemdManager,
144
+
145
+ msh: ObjMarshalerManager,
146
+ ) -> None:
147
+ super().__init__()
148
+
149
+ self._spec = spec
150
+ self._home = home
151
+ self._time = time
152
+
153
+ self._deploys = deploys
154
+ self._paths = paths
155
+ self._apps = apps
156
+ self._conf = conf
157
+ self._systemd = systemd
158
+
159
+ self._msh = msh
160
+
161
+ #
162
+
163
+ @property
164
+ def tags(self) -> DeployTagMap:
165
+ return DeployTagMap(
166
+ self._time,
167
+ self._spec.key(),
168
+ )
169
+
170
+ def render_path(self, pth: DeployPath, tags: ta.Optional[DeployTagMap] = None) -> str:
171
+ return os.path.join(self._home, pth.render(tags if tags is not None else self.tags))
172
+
173
+ @property
174
+ def dir(self) -> str:
175
+ return self.render_path(self._deploys.DEPLOY_DIR)
176
+
177
+ #
178
+
179
+ async def drive_deploy(self) -> None:
180
+ spec_json = json_dumps_pretty(self._msh.marshal_obj(self._spec))
181
+
182
+ #
183
+
184
+ das: ta.Set[DeployApp] = {a.app for a in self._spec.apps}
185
+ las: ta.Set[DeployApp] = set(self._spec.app_links.apps)
186
+ if (ras := das & las):
187
+ raise RuntimeError(f'Must not specify apps as both deploy and link: {sorted(a.s for a in ras)}')
188
+
189
+ #
190
+
191
+ self._paths.validate_deploy_paths()
192
+
193
+ #
194
+
195
+ os.makedirs(self.dir)
196
+
197
+ #
198
+
199
+ spec_file = self.render_path(self._deploys.DEPLOY_SPEC_FILE)
200
+ with open(spec_file, 'w') as f: # noqa
201
+ f.write(spec_json)
202
+
203
+ #
204
+
205
+ deploying_link = self.render_path(self._deploys.DEPLOYING_DEPLOY_LINK)
206
+ current_link = self.render_path(self._deploys.CURRENT_DEPLOY_LINK)
207
+
208
+ #
209
+
210
+ if os.path.exists(deploying_link):
211
+ os.unlink(deploying_link)
212
+ relative_symlink(
213
+ self.dir,
214
+ deploying_link,
215
+ target_is_directory=True,
216
+ make_dirs=True,
217
+ )
218
+
219
+ #
220
+
221
+ for md in [
222
+ self._deploys.APPS_DEPLOY_DIR,
223
+ self._deploys.CONFS_DEPLOY_DIR,
224
+ ]:
225
+ os.makedirs(self.render_path(md))
226
+
227
+ #
228
+
229
+ if not self._spec.app_links.exclude_unspecified:
230
+ cad = abs_real_path(os.path.join(current_link, 'apps'))
231
+ if os.path.exists(cad):
232
+ for d in os.listdir(cad):
233
+ if (da := DeployApp(d)) not in das:
234
+ las.add(da)
235
+
236
+ for la in self._spec.app_links.apps:
237
+ await self._drive_app_link(
238
+ la,
239
+ current_link,
240
+ )
241
+
242
+ for app in self._spec.apps:
243
+ await self._drive_app_deploy(
244
+ app,
245
+ )
246
+
247
+ #
248
+
249
+ os.replace(deploying_link, current_link)
250
+
251
+ #
252
+
253
+ await self._systemd.sync_systemd(
254
+ self._spec.systemd,
255
+ self._home,
256
+ os.path.join(self.dir, 'conf', 'systemd'), # FIXME
257
+ )
258
+
259
+ #
260
+
261
+ self._deploys.make_home_current_link(self._home)
262
+
263
+ #
264
+
265
+ async def _drive_app_deploy(self, app: DeployAppSpec) -> None:
266
+ pa = await self._apps.prepare_app(
267
+ app,
268
+ self._home,
269
+ self.tags,
270
+ )
271
+
272
+ #
273
+
274
+ app_link = self.render_path(self._deploys.APP_DEPLOY_LINK, pa.tags)
275
+ relative_symlink(
276
+ pa.dir,
277
+ app_link,
278
+ target_is_directory=True,
279
+ make_dirs=True,
280
+ )
281
+
282
+ #
283
+
284
+ await self._drive_app_configure(pa)
285
+
286
+ async def _drive_app_link(
287
+ self,
288
+ app: DeployApp,
289
+ current_link: str,
290
+ ) -> None:
291
+ app_link = os.path.join(abs_real_path(current_link), 'apps', app.s)
292
+ check.state(os.path.islink(app_link))
293
+
294
+ app_dir = abs_real_path(app_link)
295
+ check.state(os.path.isdir(app_dir))
296
+
297
+ #
298
+
299
+ pa = await self._apps.prepare_app_link(
300
+ self.tags,
301
+ app_dir,
302
+ )
303
+
304
+ #
305
+
306
+ relative_symlink(
307
+ app_dir,
308
+ os.path.join(self.dir, 'apps', app.s),
309
+ target_is_directory=True,
310
+ )
311
+
312
+ #
313
+
314
+ await self._drive_app_configure(pa)
315
+
316
+ async def _drive_app_configure(
317
+ self,
318
+ pa: DeployAppManager.PreparedApp,
319
+ ) -> None:
320
+ deploy_conf_dir = self.render_path(self._deploys.CONFS_DEPLOY_DIR)
321
+ if pa.spec.conf is not None:
322
+ await self._conf.link_app_conf(
323
+ pa.spec.conf,
324
+ pa.tags,
325
+ check.non_empty_str(pa.conf_dir),
326
+ deploy_conf_dir,
327
+ )
@@ -16,15 +16,16 @@ from .commands import DeployCommand
16
16
  from .commands import DeployCommandExecutor
17
17
  from .conf.inject import bind_deploy_conf
18
18
  from .config import DeployConfig
19
+ from .deploy import DeployDriver
20
+ from .deploy import DeployDriverFactory
19
21
  from .deploy import DeployManager
20
- from .driver import DeployDriver
21
- from .driver import DeployDriverFactory
22
22
  from .git import DeployGitManager
23
23
  from .inject_ import bind_deploy_manager
24
24
  from .interp import InterpCommand
25
25
  from .interp import InterpCommandExecutor
26
26
  from .paths.inject import bind_deploy_paths
27
27
  from .specs import DeploySpec
28
+ from .systemd import DeploySystemdManager
28
29
  from .tags import DeployTime
29
30
  from .tmp import DeployHomeAtomics
30
31
  from .tmp import DeployTmpManager
@@ -101,13 +102,10 @@ def bind_deploy(
101
102
 
102
103
  lst.extend([
103
104
  bind_deploy_manager(DeployAppManager),
104
-
105
105
  bind_deploy_manager(DeployGitManager),
106
-
107
106
  bind_deploy_manager(DeployManager),
108
-
107
+ bind_deploy_manager(DeploySystemdManager),
109
108
  bind_deploy_manager(DeployTmpManager),
110
-
111
109
  bind_deploy_manager(DeployVenvManager),
112
110
  ])
113
111
 
@@ -0,0 +1,8 @@
1
+ # ruff: noqa: UP006 UP007
2
+ """
3
+ verify - nginx -t
4
+ """
5
+
6
+
7
+ class DeployNginxManager:
8
+ pass
@@ -33,6 +33,10 @@ class DeployPathError(Exception):
33
33
 
34
34
 
35
35
  class DeployPathRenderable(abc.ABC):
36
+ @cached_nullary
37
+ def __str__(self) -> str:
38
+ return self.render(None)
39
+
36
40
  @abc.abstractmethod
37
41
  def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
38
42
  raise NotImplementedError
@@ -174,7 +178,7 @@ class FileDeployPathPart(DeployPathPart):
174
178
 
175
179
 
176
180
  @dc.dataclass(frozen=True)
177
- class DeployPath:
181
+ class DeployPath(DeployPathRenderable):
178
182
  parts: ta.Sequence[DeployPathPart]
179
183
 
180
184
  @property
@@ -91,6 +91,22 @@ class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
91
91
  return DeployAppKey(self._key_str())
92
92
 
93
93
 
94
+ @dc.dataclass(frozen=True)
95
+ class DeployAppLinksSpec:
96
+ apps: ta.Sequence[DeployApp] = ()
97
+
98
+ exclude_unspecified: bool = False
99
+
100
+
101
+ ##
102
+
103
+
104
+ @dc.dataclass(frozen=True)
105
+ class DeploySystemdSpec:
106
+ # ~/.config/systemd/user/
107
+ unit_dir: ta.Optional[str] = None
108
+
109
+
94
110
  ##
95
111
 
96
112
 
@@ -98,7 +114,11 @@ class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
98
114
  class DeploySpec(DeploySpecKeyed[DeployKey]):
99
115
  home: DeployHome
100
116
 
101
- apps: ta.Sequence[DeployAppSpec]
117
+ apps: ta.Sequence[DeployAppSpec] = ()
118
+
119
+ app_links: DeployAppLinksSpec = DeployAppLinksSpec()
120
+
121
+ systemd: ta.Optional[DeploySystemdSpec] = None
102
122
 
103
123
  def __post_init__(self) -> None:
104
124
  check.non_empty_str(self.home)
@@ -0,0 +1,131 @@
1
+ # ruff: noqa: UP006 UP007
2
+ """
3
+ TODO:
4
+ - verify - systemd-analyze
5
+ - sudo loginctl enable-linger "$USER"
6
+ - idemp kill services that shouldn't be running, start ones that should
7
+ - ideally only those defined by links to deploy home
8
+ - ominfra.systemd / x.sd_orphans
9
+ """
10
+ import os.path
11
+ import sys
12
+ import typing as ta
13
+
14
+ from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
15
+ from omlish.lite.check import check
16
+ from omlish.os.paths import abs_real_path
17
+ from omlish.os.paths import is_path_in_dir
18
+
19
+ from .specs import DeploySystemdSpec
20
+ from .tmp import DeployHomeAtomics
21
+ from .types import DeployHome
22
+
23
+
24
+ class DeploySystemdManager:
25
+ def __init__(
26
+ self,
27
+ *,
28
+ atomics: DeployHomeAtomics,
29
+ ) -> None:
30
+ super().__init__()
31
+
32
+ self._atomics = atomics
33
+
34
+ def _scan_link_dir(
35
+ self,
36
+ d: str,
37
+ *,
38
+ strict: bool = False,
39
+ ) -> ta.Dict[str, str]:
40
+ o: ta.Dict[str, str] = {}
41
+ for f in os.listdir(d):
42
+ fp = os.path.join(d, f)
43
+ if strict:
44
+ check.state(os.path.islink(fp))
45
+ o[f] = abs_real_path(fp)
46
+ return o
47
+
48
+ async def sync_systemd(
49
+ self,
50
+ spec: ta.Optional[DeploySystemdSpec],
51
+ home: DeployHome,
52
+ conf_dir: str,
53
+ ) -> None:
54
+ check.non_empty_str(home)
55
+
56
+ if not spec:
57
+ return
58
+
59
+ #
60
+
61
+ if not (ud := spec.unit_dir):
62
+ return
63
+
64
+ ud = abs_real_path(os.path.expanduser(ud))
65
+
66
+ os.makedirs(ud, exist_ok=True)
67
+
68
+ #
69
+
70
+ uld = {
71
+ n: p
72
+ for n, p in self._scan_link_dir(ud).items()
73
+ if is_path_in_dir(home, p)
74
+ }
75
+
76
+ if os.path.exists(conf_dir):
77
+ cld = self._scan_link_dir(conf_dir, strict=True)
78
+ else:
79
+ cld = {}
80
+
81
+ #
82
+
83
+ ns = sorted(set(uld) | set(cld))
84
+
85
+ for n in ns:
86
+ cl = cld.get(n)
87
+ if cl is None:
88
+ os.unlink(os.path.join(ud, n))
89
+ else:
90
+ with self._atomics(home).begin_atomic_path_swap( # noqa
91
+ 'file',
92
+ os.path.join(ud, n),
93
+ auto_commit=True,
94
+ skip_root_dir_check=True,
95
+ ) as dst_swap:
96
+ os.unlink(dst_swap.tmp_path)
97
+ os.symlink(
98
+ os.path.relpath(cl, os.path.dirname(dst_swap.dst_path)),
99
+ dst_swap.tmp_path,
100
+ )
101
+
102
+ #
103
+
104
+ if sys.platform == 'linux':
105
+ async def reload() -> None:
106
+ await asyncio_subprocesses.check_call('systemctl', '--user', 'daemon-reload')
107
+
108
+ await reload()
109
+
110
+ num_deleted = 0
111
+ for n in ns:
112
+ if n.endswith('.service'):
113
+ cl = cld.get(n)
114
+ ul = uld.get(n)
115
+ if cl is not None:
116
+ if ul is None:
117
+ cs = ['enable', 'start']
118
+ else:
119
+ cs = ['restart']
120
+ else: # noqa
121
+ if ul is not None:
122
+ cs = ['stop']
123
+ num_deleted += 1
124
+ else:
125
+ cs = []
126
+
127
+ for c in cs:
128
+ await asyncio_subprocesses.check_call('systemctl', '--user', c, n)
129
+
130
+ if num_deleted:
131
+ await reload()
@@ -29,7 +29,7 @@ DEPLOY_TAG_ILLEGAL_STRS: ta.AbstractSet[str] = frozenset([
29
29
  ##
30
30
 
31
31
 
32
- @dc.dataclass(frozen=True)
32
+ @dc.dataclass(frozen=True, order=True)
33
33
  class DeployTag(abc.ABC): # noqa
34
34
  s: str
35
35
 
@@ -5,6 +5,7 @@ TODO:
5
5
  - share more code with pyproject?
6
6
  """
7
7
  import os.path
8
+ import shutil
8
9
 
9
10
  from omdev.interp.default import get_default_interp_resolver
10
11
  from omdev.interp.types import InterpSpecifier
@@ -12,14 +13,12 @@ from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
12
13
  from omlish.lite.check import check
13
14
 
14
15
  from .specs import DeployVenvSpec
15
- from .types import DeployHome
16
16
 
17
17
 
18
18
  class DeployVenvManager:
19
19
  async def setup_venv(
20
20
  self,
21
21
  spec: DeployVenvSpec,
22
- home: DeployHome,
23
22
  git_dir: str,
24
23
  venv_dir: str,
25
24
  ) -> None:
@@ -46,9 +45,12 @@ class DeployVenvManager:
46
45
 
47
46
  if os.path.isfile(reqs_txt):
48
47
  if spec.use_uv:
49
- await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
50
- pip_cmd = ['-m', 'uv', 'pip']
48
+ if shutil.which('uv') is not None:
49
+ pip_cmd = ['uv', 'pip']
50
+ else:
51
+ await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
52
+ pip_cmd = [venv_exe, '-m', 'uv', 'pip']
51
53
  else:
52
- pip_cmd = ['-m', 'pip']
54
+ pip_cmd = [venv_exe, '-m', 'pip']
53
55
 
54
- await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
56
+ await asyncio_subprocesses.check_call(*pip_cmd, 'install', '-r', reqs_txt, cwd=venv_dir)