ominfra 0.0.0.dev166__py3-none-any.whl → 0.0.0.dev168__py3-none-any.whl

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.
ominfra/scripts/manage.py CHANGED
@@ -100,9 +100,9 @@ CallableVersionOperator = ta.Callable[['Version', str], bool]
100
100
  CommandT = ta.TypeVar('CommandT', bound='Command')
101
101
  CommandOutputT = ta.TypeVar('CommandOutputT', bound='Command.Output')
102
102
 
103
- # deploy/paths.py
103
+ # deploy/types.py
104
104
  DeployPathKind = ta.Literal['dir', 'file'] # ta.TypeAlias
105
- DeployPathPlaceholder = ta.Literal['app', 'tag'] # ta.TypeAlias
105
+ DeployPathPlaceholder = ta.Literal['app', 'tag', 'conf'] # ta.TypeAlias
106
106
 
107
107
  # ../../omlish/argparse/cli.py
108
108
  ArgparseCommandFn = ta.Callable[[], ta.Optional[int]] # ta.TypeAlias
@@ -1379,23 +1379,6 @@ class DeployConfig:
1379
1379
  deploy_home: ta.Optional[str] = None
1380
1380
 
1381
1381
 
1382
- ########################################
1383
- # ../deploy/types.py
1384
-
1385
-
1386
- DeployHome = ta.NewType('DeployHome', str)
1387
-
1388
- DeployApp = ta.NewType('DeployApp', str)
1389
- DeployTag = ta.NewType('DeployTag', str)
1390
- DeployRev = ta.NewType('DeployRev', str)
1391
- DeployKey = ta.NewType('DeployKey', str)
1392
-
1393
-
1394
- class DeployAppTag(ta.NamedTuple):
1395
- app: DeployApp
1396
- tag: DeployTag
1397
-
1398
-
1399
1382
  ########################################
1400
1383
  # ../../pyremote.py
1401
1384
  """
@@ -2074,6 +2057,8 @@ def async_cached_nullary(fn): # ta.Callable[..., T]) -> ta.Callable[..., T]:
2074
2057
  """
2075
2058
  TODO:
2076
2059
  - def maybe(v: lang.Maybe[T])
2060
+ - def not_ ?
2061
+ - ** class @dataclass Raise - user message should be able to be an exception type or instance or factory
2077
2062
  """
2078
2063
 
2079
2064
 
@@ -2732,6 +2717,37 @@ def snake_case(name: str) -> str:
2732
2717
  ##
2733
2718
 
2734
2719
 
2720
+ def strip_with_newline(s: str) -> str:
2721
+ if not s:
2722
+ return ''
2723
+ return s.strip() + '\n'
2724
+
2725
+
2726
+ @ta.overload
2727
+ def split_keep_delimiter(s: str, d: str) -> str:
2728
+ ...
2729
+
2730
+
2731
+ @ta.overload
2732
+ def split_keep_delimiter(s: bytes, d: bytes) -> bytes:
2733
+ ...
2734
+
2735
+
2736
+ def split_keep_delimiter(s, d):
2737
+ ps = []
2738
+ i = 0
2739
+ while i < len(s):
2740
+ if (n := s.find(d, i)) < i:
2741
+ ps.append(s[i:])
2742
+ break
2743
+ ps.append(s[i:n + 1])
2744
+ i = n + 1
2745
+ return ps
2746
+
2747
+
2748
+ ##
2749
+
2750
+
2735
2751
  def is_dunder(name: str) -> bool:
2736
2752
  return (
2737
2753
  name[:2] == name[-2:] == '__' and
@@ -3392,6 +3408,42 @@ class LinuxOsRelease:
3392
3408
  return dct
3393
3409
 
3394
3410
 
3411
+ ########################################
3412
+ # ../../../omlish/os/paths.py
3413
+
3414
+
3415
+ def abs_real_path(p: str) -> str:
3416
+ return os.path.abspath(os.path.realpath(p))
3417
+
3418
+
3419
+ def is_path_in_dir(base_dir: str, target_path: str) -> bool:
3420
+ base_dir = abs_real_path(base_dir)
3421
+ target_path = abs_real_path(target_path)
3422
+
3423
+ return target_path.startswith(base_dir + os.path.sep)
3424
+
3425
+
3426
+ def relative_symlink(
3427
+ src: str,
3428
+ dst: str,
3429
+ *,
3430
+ target_is_directory: bool = False,
3431
+ dir_fd: ta.Optional[int] = None,
3432
+ make_dirs: bool = False,
3433
+ **kwargs: ta.Any,
3434
+ ) -> None:
3435
+ if make_dirs:
3436
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
3437
+
3438
+ os.symlink(
3439
+ os.path.relpath(src, os.path.dirname(dst)),
3440
+ dst,
3441
+ target_is_directory=target_is_directory,
3442
+ dir_fd=dir_fd,
3443
+ **kwargs,
3444
+ )
3445
+
3446
+
3395
3447
  ########################################
3396
3448
  # ../../../omdev/packaging/specifiers.py
3397
3449
  # Copyright (c) Donald Stufft and individual contributors.
@@ -4065,455 +4117,194 @@ def build_command_name_map(crs: CommandRegistrations) -> CommandNameMap:
4065
4117
 
4066
4118
 
4067
4119
  ########################################
4068
- # ../deploy/paths.py
4069
- """
4070
- TODO:
4071
- - run/pidfile
4072
- - logs/...
4073
- - current symlink
4074
- - conf/{nginx,supervisor}
4075
- - env/?
4076
- """
4120
+ # ../deploy/types.py
4077
4121
 
4078
4122
 
4079
4123
  ##
4080
4124
 
4081
4125
 
4082
- DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER = '@'
4083
- DEPLOY_PATH_PLACEHOLDER_SEPARATORS = '-.'
4126
+ DeployHome = ta.NewType('DeployHome', str)
4084
4127
 
4085
- DEPLOY_PATH_PLACEHOLDERS: ta.FrozenSet[str] = frozenset([
4086
- 'app',
4087
- 'tag',
4088
- ])
4128
+ DeployApp = ta.NewType('DeployApp', str)
4129
+ DeployTag = ta.NewType('DeployTag', str)
4130
+ DeployRev = ta.NewType('DeployRev', str)
4131
+ DeployKey = ta.NewType('DeployKey', str)
4089
4132
 
4090
4133
 
4091
- class DeployPathError(Exception):
4092
- pass
4134
+ ##
4093
4135
 
4094
4136
 
4095
4137
  @dc.dataclass(frozen=True)
4096
- class DeployPathPart(abc.ABC): # noqa
4097
- @property
4098
- @abc.abstractmethod
4099
- def kind(self) -> DeployPathKind:
4100
- raise NotImplementedError
4138
+ class DeployAppTag:
4139
+ app: DeployApp
4140
+ tag: DeployTag
4101
4141
 
4102
- @abc.abstractmethod
4103
- def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4104
- raise NotImplementedError
4142
+ def __post_init__(self) -> None:
4143
+ for s in [self.app, self.tag]:
4144
+ check.non_empty_str(s)
4145
+ check.equal(s, s.strip())
4105
4146
 
4147
+ def placeholders(self) -> ta.Mapping[DeployPathPlaceholder, str]:
4148
+ return {
4149
+ 'app': self.app,
4150
+ 'tag': self.tag,
4151
+ }
4106
4152
 
4107
- #
4108
4153
 
4154
+ ########################################
4155
+ # ../remote/config.py
4109
4156
 
4110
- class DirDeployPathPart(DeployPathPart, abc.ABC):
4111
- @property
4112
- def kind(self) -> DeployPathKind:
4113
- return 'dir'
4114
4157
 
4115
- @classmethod
4116
- def parse(cls, s: str) -> 'DirDeployPathPart':
4117
- if DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER in s:
4118
- check.equal(s[0], DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER)
4119
- return PlaceholderDirDeployPathPart(s[1:])
4120
- else:
4121
- return ConstDirDeployPathPart(s)
4158
+ @dc.dataclass(frozen=True)
4159
+ class RemoteConfig:
4160
+ payload_file: ta.Optional[str] = None
4122
4161
 
4162
+ set_pgid: bool = True
4123
4163
 
4124
- class FileDeployPathPart(DeployPathPart, abc.ABC):
4125
- @property
4126
- def kind(self) -> DeployPathKind:
4127
- return 'file'
4164
+ deathsig: ta.Optional[str] = 'KILL'
4128
4165
 
4129
- @classmethod
4130
- def parse(cls, s: str) -> 'FileDeployPathPart':
4131
- if DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER in s:
4132
- check.equal(s[0], DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER)
4133
- if not any(c in s for c in DEPLOY_PATH_PLACEHOLDER_SEPARATORS):
4134
- return PlaceholderFileDeployPathPart(s[1:], '')
4135
- else:
4136
- p = min(f for c in DEPLOY_PATH_PLACEHOLDER_SEPARATORS if (f := s.find(c)) > 0)
4137
- return PlaceholderFileDeployPathPart(s[1:p], s[p:])
4138
- else:
4139
- return ConstFileDeployPathPart(s)
4166
+ pycharm_remote_debug: ta.Optional[PycharmRemoteDebug] = None
4140
4167
 
4168
+ forward_logging: bool = True
4141
4169
 
4142
- #
4170
+ timebomb_delay_s: ta.Optional[float] = 60 * 60.
4143
4171
 
4172
+ heartbeat_interval_s: float = 3.
4144
4173
 
4145
- @dc.dataclass(frozen=True)
4146
- class ConstDeployPathPart(DeployPathPart, abc.ABC):
4147
- name: str
4148
4174
 
4149
- def __post_init__(self) -> None:
4150
- check.non_empty_str(self.name)
4151
- check.not_in('/', self.name)
4152
- check.not_in(DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER, self.name)
4175
+ ########################################
4176
+ # ../remote/payload.py
4153
4177
 
4154
- def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4155
- return self.name
4156
4178
 
4179
+ RemoteExecutionPayloadFile = ta.NewType('RemoteExecutionPayloadFile', str)
4157
4180
 
4158
- class ConstDirDeployPathPart(ConstDeployPathPart, DirDeployPathPart):
4159
- pass
4160
4181
 
4182
+ @cached_nullary
4183
+ def _get_self_src() -> str:
4184
+ return inspect.getsource(sys.modules[__name__])
4161
4185
 
4162
- class ConstFileDeployPathPart(ConstDeployPathPart, FileDeployPathPart):
4163
- pass
4164
4186
 
4187
+ def _is_src_amalg(src: str) -> bool:
4188
+ for l in src.splitlines(): # noqa
4189
+ if l.startswith('# @omlish-amalg-output '):
4190
+ return True
4191
+ return False
4165
4192
 
4166
- #
4167
4193
 
4194
+ @cached_nullary
4195
+ def _is_self_amalg() -> bool:
4196
+ return _is_src_amalg(_get_self_src())
4168
4197
 
4169
- @dc.dataclass(frozen=True)
4170
- class PlaceholderDeployPathPart(DeployPathPart, abc.ABC):
4171
- placeholder: str # DeployPathPlaceholder
4172
4198
 
4173
- def __post_init__(self) -> None:
4174
- check.non_empty_str(self.placeholder)
4175
- for c in [*DEPLOY_PATH_PLACEHOLDER_SEPARATORS, DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER, '/']:
4176
- check.not_in(c, self.placeholder)
4177
- check.in_(self.placeholder, DEPLOY_PATH_PLACEHOLDERS)
4199
+ def get_remote_payload_src(
4200
+ *,
4201
+ file: ta.Optional[RemoteExecutionPayloadFile],
4202
+ ) -> str:
4203
+ if file is not None:
4204
+ with open(file) as f:
4205
+ return f.read()
4178
4206
 
4179
- def _render_placeholder(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4180
- if placeholders is not None:
4181
- return placeholders[self.placeholder] # type: ignore
4182
- else:
4183
- return DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER + self.placeholder
4207
+ if _is_self_amalg():
4208
+ return _get_self_src()
4184
4209
 
4210
+ import importlib.resources
4211
+ return importlib.resources.files(__package__.split('.')[0] + '.scripts').joinpath('manage.py').read_text()
4185
4212
 
4186
- @dc.dataclass(frozen=True)
4187
- class PlaceholderDirDeployPathPart(PlaceholderDeployPathPart, DirDeployPathPart):
4188
- def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4189
- return self._render_placeholder(placeholders)
4190
4213
 
4214
+ ########################################
4215
+ # ../system/platforms.py
4191
4216
 
4192
- @dc.dataclass(frozen=True)
4193
- class PlaceholderFileDeployPathPart(PlaceholderDeployPathPart, FileDeployPathPart):
4194
- suffix: str
4195
4217
 
4196
- def __post_init__(self) -> None:
4197
- super().__post_init__()
4198
- if self.suffix:
4199
- for c in [DEPLOY_PATH_PLACEHOLDER_PLACEHOLDER, '/']:
4200
- check.not_in(c, self.suffix)
4218
+ ##
4201
4219
 
4202
- def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4203
- return self._render_placeholder(placeholders) + self.suffix
4204
4220
 
4221
+ @dc.dataclass(frozen=True)
4222
+ class Platform(abc.ABC): # noqa
4223
+ pass
4205
4224
 
4206
- ##
4207
4225
 
4226
+ class LinuxPlatform(Platform, abc.ABC):
4227
+ pass
4208
4228
 
4209
- @dc.dataclass(frozen=True)
4210
- class DeployPath:
4211
- parts: ta.Sequence[DeployPathPart]
4212
4229
 
4213
- def __post_init__(self) -> None:
4214
- hash(self)
4230
+ class UbuntuPlatform(LinuxPlatform):
4231
+ pass
4215
4232
 
4216
- check.not_empty(self.parts)
4217
- for p in self.parts[:-1]:
4218
- check.equal(p.kind, 'dir')
4219
4233
 
4220
- pd = {}
4221
- for i, p in enumerate(self.parts):
4222
- if isinstance(p, PlaceholderDeployPathPart):
4223
- if p.placeholder in pd:
4224
- raise DeployPathError('Duplicate placeholders in path', self)
4225
- pd[p.placeholder] = i
4234
+ class AmazonLinuxPlatform(LinuxPlatform):
4235
+ pass
4226
4236
 
4227
- if 'tag' in pd:
4228
- if 'app' not in pd or pd['app'] >= pd['tag']:
4229
- raise DeployPathError('Tag placeholder in path without preceding app', self)
4230
4237
 
4231
- @property
4232
- def kind(self) -> ta.Literal['file', 'dir']:
4233
- return self.parts[-1].kind
4238
+ class GenericLinuxPlatform(LinuxPlatform):
4239
+ pass
4234
4240
 
4235
- def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
4236
- return os.path.join( # noqa
4237
- *[p.render(placeholders) for p in self.parts],
4238
- *([''] if self.kind == 'dir' else []),
4239
- )
4240
4241
 
4241
- @classmethod
4242
- def parse(cls, s: str) -> 'DeployPath':
4243
- tail_parse: ta.Callable[[str], DeployPathPart]
4244
- if s.endswith('/'):
4245
- tail_parse = DirDeployPathPart.parse
4246
- s = s[:-1]
4247
- else:
4248
- tail_parse = FileDeployPathPart.parse
4249
- ps = check.non_empty_str(s).split('/')
4250
- return cls((
4251
- *([DirDeployPathPart.parse(p) for p in ps[:-1]] if len(ps) > 1 else []),
4252
- tail_parse(ps[-1]),
4253
- ))
4242
+ class DarwinPlatform(Platform):
4243
+ pass
4244
+
4245
+
4246
+ class UnknownPlatform(Platform):
4247
+ pass
4254
4248
 
4255
4249
 
4256
4250
  ##
4257
4251
 
4258
4252
 
4259
- class DeployPathOwner(abc.ABC):
4260
- @abc.abstractmethod
4261
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
4262
- raise NotImplementedError
4253
+ def _detect_system_platform() -> Platform:
4254
+ plat = sys.platform
4263
4255
 
4256
+ if plat == 'linux':
4257
+ if (osr := LinuxOsRelease.read()) is None:
4258
+ return GenericLinuxPlatform()
4264
4259
 
4265
- class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
4266
- def __init__(
4267
- self,
4268
- *args: ta.Any,
4269
- owned_dir: str,
4270
- deploy_home: ta.Optional[DeployHome],
4271
- **kwargs: ta.Any,
4272
- ) -> None:
4273
- super().__init__(*args, **kwargs)
4260
+ if osr.id == 'amzn':
4261
+ return AmazonLinuxPlatform()
4274
4262
 
4275
- check.not_in('/', owned_dir)
4276
- self._owned_dir: str = check.non_empty_str(owned_dir)
4263
+ elif osr.id == 'ubuntu':
4264
+ return UbuntuPlatform()
4277
4265
 
4278
- self._deploy_home = deploy_home
4266
+ else:
4267
+ return GenericLinuxPlatform()
4279
4268
 
4280
- self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
4269
+ elif plat == 'darwin':
4270
+ return DarwinPlatform()
4281
4271
 
4282
- @cached_nullary
4283
- def _dir(self) -> str:
4284
- return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
4272
+ else:
4273
+ return UnknownPlatform()
4285
4274
 
4286
- @cached_nullary
4287
- def _make_dir(self) -> str:
4288
- if not os.path.isdir(d := self._dir()):
4289
- os.makedirs(d, exist_ok=True)
4290
- return d
4291
4275
 
4292
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
4293
- return self._owned_deploy_paths
4276
+ @cached_nullary
4277
+ def detect_system_platform() -> Platform:
4278
+ platform = _detect_system_platform()
4279
+ log.info('Detected platform: %r', platform)
4280
+ return platform
4294
4281
 
4295
4282
 
4296
4283
  ########################################
4297
- # ../deploy/specs.py
4284
+ # ../targets/targets.py
4285
+ """
4286
+ It's desugaring. Subprocess and locals are only leafs. Retain an origin?
4287
+ ** TWO LAYERS ** - ManageTarget is user-facing, ConnectorTarget is bound, internal
4288
+ """
4298
4289
 
4299
4290
 
4300
4291
  ##
4301
4292
 
4302
4293
 
4303
- @dc.dataclass(frozen=True)
4304
- class DeployGitRepo:
4305
- host: ta.Optional[str] = None
4306
- username: ta.Optional[str] = None
4307
- path: ta.Optional[str] = None
4294
+ class ManageTarget(abc.ABC): # noqa
4295
+ def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4296
+ super().__init_subclass__(**kwargs)
4297
+
4298
+ check.state(cls.__name__.endswith('ManageTarget'))
4308
4299
 
4309
- def __post_init__(self) -> None:
4310
- check.not_in('..', check.non_empty_str(self.host))
4311
- check.not_in('.', check.non_empty_str(self.path))
4300
+
4301
+ #
4312
4302
 
4313
4303
 
4314
4304
  @dc.dataclass(frozen=True)
4315
- class DeployGitCheckout:
4316
- repo: DeployGitRepo
4317
- rev: DeployRev
4318
-
4319
- subtrees: ta.Optional[ta.Sequence[str]] = None
4320
-
4321
- def __post_init__(self) -> None:
4322
- hash(self)
4323
- check.non_empty_str(self.rev)
4324
- if self.subtrees is not None:
4325
- for st in self.subtrees:
4326
- check.non_empty_str(st)
4327
-
4328
-
4329
- ##
4330
-
4331
-
4332
- @dc.dataclass(frozen=True)
4333
- class DeployVenvSpec:
4334
- interp: ta.Optional[str] = None
4335
-
4336
- requirements_files: ta.Optional[ta.Sequence[str]] = None
4337
- extra_dependencies: ta.Optional[ta.Sequence[str]] = None
4338
-
4339
- use_uv: bool = False
4340
-
4341
- def __post_init__(self) -> None:
4342
- hash(self)
4343
-
4344
-
4345
- ##
4346
-
4347
-
4348
- @dc.dataclass(frozen=True)
4349
- class DeploySpec:
4350
- app: DeployApp
4351
- checkout: DeployGitCheckout
4352
-
4353
- venv: ta.Optional[DeployVenvSpec] = None
4354
-
4355
- def __post_init__(self) -> None:
4356
- hash(self)
4357
-
4358
- @cached_nullary
4359
- def key(self) -> DeployKey:
4360
- return DeployKey(hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8])
4361
-
4362
-
4363
- ########################################
4364
- # ../remote/config.py
4365
-
4366
-
4367
- @dc.dataclass(frozen=True)
4368
- class RemoteConfig:
4369
- payload_file: ta.Optional[str] = None
4370
-
4371
- set_pgid: bool = True
4372
-
4373
- deathsig: ta.Optional[str] = 'KILL'
4374
-
4375
- pycharm_remote_debug: ta.Optional[PycharmRemoteDebug] = None
4376
-
4377
- forward_logging: bool = True
4378
-
4379
- timebomb_delay_s: ta.Optional[float] = 60 * 60.
4380
-
4381
- heartbeat_interval_s: float = 3.
4382
-
4383
-
4384
- ########################################
4385
- # ../remote/payload.py
4386
-
4387
-
4388
- RemoteExecutionPayloadFile = ta.NewType('RemoteExecutionPayloadFile', str)
4389
-
4390
-
4391
- @cached_nullary
4392
- def _get_self_src() -> str:
4393
- return inspect.getsource(sys.modules[__name__])
4394
-
4395
-
4396
- def _is_src_amalg(src: str) -> bool:
4397
- for l in src.splitlines(): # noqa
4398
- if l.startswith('# @omlish-amalg-output '):
4399
- return True
4400
- return False
4401
-
4402
-
4403
- @cached_nullary
4404
- def _is_self_amalg() -> bool:
4405
- return _is_src_amalg(_get_self_src())
4406
-
4407
-
4408
- def get_remote_payload_src(
4409
- *,
4410
- file: ta.Optional[RemoteExecutionPayloadFile],
4411
- ) -> str:
4412
- if file is not None:
4413
- with open(file) as f:
4414
- return f.read()
4415
-
4416
- if _is_self_amalg():
4417
- return _get_self_src()
4418
-
4419
- import importlib.resources
4420
- return importlib.resources.files(__package__.split('.')[0] + '.scripts').joinpath('manage.py').read_text()
4421
-
4422
-
4423
- ########################################
4424
- # ../system/platforms.py
4425
-
4426
-
4427
- ##
4428
-
4429
-
4430
- @dc.dataclass(frozen=True)
4431
- class Platform(abc.ABC): # noqa
4432
- pass
4433
-
4434
-
4435
- class LinuxPlatform(Platform, abc.ABC):
4436
- pass
4437
-
4438
-
4439
- class UbuntuPlatform(LinuxPlatform):
4440
- pass
4441
-
4442
-
4443
- class AmazonLinuxPlatform(LinuxPlatform):
4444
- pass
4445
-
4446
-
4447
- class GenericLinuxPlatform(LinuxPlatform):
4448
- pass
4449
-
4450
-
4451
- class DarwinPlatform(Platform):
4452
- pass
4453
-
4454
-
4455
- class UnknownPlatform(Platform):
4456
- pass
4457
-
4458
-
4459
- ##
4460
-
4461
-
4462
- def _detect_system_platform() -> Platform:
4463
- plat = sys.platform
4464
-
4465
- if plat == 'linux':
4466
- if (osr := LinuxOsRelease.read()) is None:
4467
- return GenericLinuxPlatform()
4468
-
4469
- if osr.id == 'amzn':
4470
- return AmazonLinuxPlatform()
4471
-
4472
- elif osr.id == 'ubuntu':
4473
- return UbuntuPlatform()
4474
-
4475
- else:
4476
- return GenericLinuxPlatform()
4477
-
4478
- elif plat == 'darwin':
4479
- return DarwinPlatform()
4480
-
4481
- else:
4482
- return UnknownPlatform()
4483
-
4484
-
4485
- @cached_nullary
4486
- def detect_system_platform() -> Platform:
4487
- platform = _detect_system_platform()
4488
- log.info('Detected platform: %r', platform)
4489
- return platform
4490
-
4491
-
4492
- ########################################
4493
- # ../targets/targets.py
4494
- """
4495
- It's desugaring. Subprocess and locals are only leafs. Retain an origin?
4496
- ** TWO LAYERS ** - ManageTarget is user-facing, ConnectorTarget is bound, internal
4497
- """
4498
-
4499
-
4500
- ##
4501
-
4502
-
4503
- class ManageTarget(abc.ABC): # noqa
4504
- def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4505
- super().__init_subclass__(**kwargs)
4506
-
4507
- check.state(cls.__name__.endswith('ManageTarget'))
4508
-
4509
-
4510
- #
4511
-
4512
-
4513
- @dc.dataclass(frozen=True)
4514
- class PythonRemoteManageTarget:
4515
- DEFAULT_PYTHON: ta.ClassVar[str] = 'python3'
4516
- python: str = DEFAULT_PYTHON
4305
+ class PythonRemoteManageTarget:
4306
+ DEFAULT_PYTHON: ta.ClassVar[str] = 'python3'
4307
+ python: str = DEFAULT_PYTHON
4517
4308
 
4518
4309
 
4519
4310
  #
@@ -6435,7 +6226,7 @@ class AtomicPathSwapping(abc.ABC):
6435
6226
  ##
6436
6227
 
6437
6228
 
6438
- class OsRenameAtomicPathSwap(AtomicPathSwap):
6229
+ class OsReplaceAtomicPathSwap(AtomicPathSwap):
6439
6230
  def __init__(
6440
6231
  self,
6441
6232
  kind: AtomicPathSwapKind,
@@ -6463,7 +6254,7 @@ class OsRenameAtomicPathSwap(AtomicPathSwap):
6463
6254
  return self._tmp_path
6464
6255
 
6465
6256
  def _commit(self) -> None:
6466
- os.rename(self._tmp_path, self._dst_path)
6257
+ os.replace(self._tmp_path, self._dst_path)
6467
6258
 
6468
6259
  def _abort(self) -> None:
6469
6260
  shutil.rmtree(self._tmp_path, ignore_errors=True)
@@ -6510,7 +6301,7 @@ class TempDirAtomicPathSwapping(AtomicPathSwapping):
6510
6301
  else:
6511
6302
  raise TypeError(kind)
6512
6303
 
6513
- return OsRenameAtomicPathSwap(
6304
+ return OsReplaceAtomicPathSwap(
6514
6305
  kind,
6515
6306
  dst_path,
6516
6307
  tmp_path,
@@ -6749,57 +6540,359 @@ CommandExecutorMap = ta.NewType('CommandExecutorMap', ta.Mapping[ta.Type[Command
6749
6540
 
6750
6541
 
6751
6542
  ########################################
6752
- # ../deploy/tmp.py
6753
-
6754
-
6755
- class DeployTmpManager(
6756
- SingleDirDeployPathOwner,
6757
- AtomicPathSwapping,
6758
- ):
6759
- def __init__(
6760
- self,
6761
- *,
6762
- deploy_home: ta.Optional[DeployHome] = None,
6763
- ) -> None:
6764
- super().__init__(
6765
- owned_dir='tmp',
6766
- deploy_home=deploy_home,
6767
- )
6543
+ # ../deploy/paths/paths.py
6544
+ """
6545
+ TODO:
6546
+ - run/{.pid,.sock}
6547
+ - logs/...
6548
+ - current symlink
6549
+ - conf/{nginx,supervisor}
6550
+ - env/?
6551
+ - apps/<app>/shared
6552
+ """
6768
6553
 
6769
- @cached_nullary
6770
- def _swapping(self) -> AtomicPathSwapping:
6771
- return TempDirAtomicPathSwapping(
6772
- temp_dir=self._make_dir(),
6773
- root_dir=check.non_empty_str(self._deploy_home),
6774
- )
6775
6554
 
6776
- def begin_atomic_path_swap(
6777
- self,
6778
- kind: AtomicPathSwapKind,
6779
- dst_path: str,
6780
- **kwargs: ta.Any,
6781
- ) -> AtomicPathSwap:
6782
- return self._swapping().begin_atomic_path_swap(
6783
- kind,
6784
- dst_path,
6785
- **kwargs,
6786
- )
6555
+ ##
6787
6556
 
6788
6557
 
6789
- ########################################
6790
- # ../marshal.py
6558
+ DEPLOY_PATH_PLACEHOLDER_SIGIL = '@'
6559
+ DEPLOY_PATH_PLACEHOLDER_SEPARATOR = '--'
6791
6560
 
6561
+ DEPLOY_PATH_PLACEHOLDER_DELIMITERS: ta.AbstractSet[str] = frozenset([
6562
+ DEPLOY_PATH_PLACEHOLDER_SEPARATOR,
6563
+ '.',
6564
+ ])
6792
6565
 
6793
- @dc.dataclass(frozen=True)
6794
- class ObjMarshalerInstaller:
6795
- fn: ta.Callable[[ObjMarshalerManager], None]
6566
+ DEPLOY_PATH_PLACEHOLDERS: ta.FrozenSet[str] = frozenset([
6567
+ 'app',
6568
+ 'tag',
6569
+ 'conf',
6570
+ ])
6796
6571
 
6797
6572
 
6798
- ObjMarshalerInstallers = ta.NewType('ObjMarshalerInstallers', ta.Sequence[ObjMarshalerInstaller])
6573
+ class DeployPathError(Exception):
6574
+ pass
6799
6575
 
6800
6576
 
6801
- ########################################
6802
- # ../remote/channel.py
6577
+ class DeployPathRenderable(abc.ABC):
6578
+ @abc.abstractmethod
6579
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6580
+ raise NotImplementedError
6581
+
6582
+
6583
+ ##
6584
+
6585
+
6586
+ class DeployPathNamePart(DeployPathRenderable, abc.ABC):
6587
+ @classmethod
6588
+ def parse(cls, s: str) -> 'DeployPathNamePart':
6589
+ check.non_empty_str(s)
6590
+ if s.startswith(DEPLOY_PATH_PLACEHOLDER_SIGIL):
6591
+ return PlaceholderDeployPathNamePart(s[1:])
6592
+ elif s in DEPLOY_PATH_PLACEHOLDER_DELIMITERS:
6593
+ return DelimiterDeployPathNamePart(s)
6594
+ else:
6595
+ return ConstDeployPathNamePart(s)
6596
+
6597
+
6598
+ @dc.dataclass(frozen=True)
6599
+ class PlaceholderDeployPathNamePart(DeployPathNamePart):
6600
+ placeholder: str # DeployPathPlaceholder
6601
+
6602
+ def __post_init__(self) -> None:
6603
+ check.in_(self.placeholder, DEPLOY_PATH_PLACEHOLDERS)
6604
+
6605
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6606
+ if placeholders is not None:
6607
+ return placeholders[self.placeholder] # type: ignore
6608
+ else:
6609
+ return DEPLOY_PATH_PLACEHOLDER_SIGIL + self.placeholder
6610
+
6611
+
6612
+ @dc.dataclass(frozen=True)
6613
+ class DelimiterDeployPathNamePart(DeployPathNamePart):
6614
+ delimiter: str
6615
+
6616
+ def __post_init__(self) -> None:
6617
+ check.in_(self.delimiter, DEPLOY_PATH_PLACEHOLDER_DELIMITERS)
6618
+
6619
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6620
+ return self.delimiter
6621
+
6622
+
6623
+ @dc.dataclass(frozen=True)
6624
+ class ConstDeployPathNamePart(DeployPathNamePart):
6625
+ const: str
6626
+
6627
+ def __post_init__(self) -> None:
6628
+ check.non_empty_str(self.const)
6629
+ for c in [*DEPLOY_PATH_PLACEHOLDER_DELIMITERS, DEPLOY_PATH_PLACEHOLDER_SIGIL, '/']:
6630
+ check.not_in(c, self.const)
6631
+
6632
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6633
+ return self.const
6634
+
6635
+
6636
+ @dc.dataclass(frozen=True)
6637
+ class DeployPathName(DeployPathRenderable):
6638
+ parts: ta.Sequence[DeployPathNamePart]
6639
+
6640
+ def __post_init__(self) -> None:
6641
+ hash(self)
6642
+ check.not_empty(self.parts)
6643
+ for k, g in itertools.groupby(self.parts, type):
6644
+ if len(gl := list(g)) > 1:
6645
+ raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
6646
+
6647
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6648
+ return ''.join(p.render(placeholders) for p in self.parts)
6649
+
6650
+ @classmethod
6651
+ def parse(cls, s: str) -> 'DeployPathName':
6652
+ check.non_empty_str(s)
6653
+ check.not_in('/', s)
6654
+
6655
+ i = 0
6656
+ ps = []
6657
+ while i < len(s):
6658
+ ns = [(n, d) for d in DEPLOY_PATH_PLACEHOLDER_DELIMITERS if (n := s.find(d, i)) >= 0]
6659
+ if not ns:
6660
+ ps.append(s[i:])
6661
+ break
6662
+ n, d = min(ns)
6663
+ ps.append(check.non_empty_str(s[i:n]))
6664
+ ps.append(s[n:n + len(d)])
6665
+ i = n + len(d)
6666
+
6667
+ return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
6668
+
6669
+
6670
+ ##
6671
+
6672
+
6673
+ @dc.dataclass(frozen=True)
6674
+ class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
6675
+ name: DeployPathName
6676
+
6677
+ @property
6678
+ @abc.abstractmethod
6679
+ def kind(self) -> DeployPathKind:
6680
+ raise NotImplementedError
6681
+
6682
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6683
+ return self.name.render(placeholders) + ('/' if self.kind == 'dir' else '')
6684
+
6685
+ @classmethod
6686
+ def parse(cls, s: str) -> 'DeployPathPart':
6687
+ if (is_dir := s.endswith('/')):
6688
+ s = s[:-1]
6689
+ check.non_empty_str(s)
6690
+ check.not_in('/', s)
6691
+
6692
+ n = DeployPathName.parse(s)
6693
+ if is_dir:
6694
+ return DirDeployPathPart(n)
6695
+ else:
6696
+ return FileDeployPathPart(n)
6697
+
6698
+
6699
+ class DirDeployPathPart(DeployPathPart):
6700
+ @property
6701
+ def kind(self) -> DeployPathKind:
6702
+ return 'dir'
6703
+
6704
+
6705
+ class FileDeployPathPart(DeployPathPart):
6706
+ @property
6707
+ def kind(self) -> DeployPathKind:
6708
+ return 'file'
6709
+
6710
+
6711
+ #
6712
+
6713
+
6714
+ @dc.dataclass(frozen=True)
6715
+ class DeployPath:
6716
+ parts: ta.Sequence[DeployPathPart]
6717
+
6718
+ @property
6719
+ def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
6720
+ for p in self.parts:
6721
+ yield from p.name.parts
6722
+
6723
+ def __post_init__(self) -> None:
6724
+ hash(self)
6725
+ check.not_empty(self.parts)
6726
+ for p in self.parts[:-1]:
6727
+ check.equal(p.kind, 'dir')
6728
+
6729
+ pd: ta.Dict[DeployPathPlaceholder, ta.List[int]] = {}
6730
+ for i, np in enumerate(self.name_parts):
6731
+ if isinstance(np, PlaceholderDeployPathNamePart):
6732
+ pd.setdefault(ta.cast(DeployPathPlaceholder, np.placeholder), []).append(i)
6733
+
6734
+ # if 'tag' in pd and 'app' not in pd:
6735
+ # raise DeployPathError('Tag placeholder in path without app', self)
6736
+
6737
+ @property
6738
+ def kind(self) -> ta.Literal['file', 'dir']:
6739
+ return self.parts[-1].kind
6740
+
6741
+ def render(self, placeholders: ta.Optional[ta.Mapping[DeployPathPlaceholder, str]] = None) -> str:
6742
+ return ''.join([p.render(placeholders) for p in self.parts])
6743
+
6744
+ @classmethod
6745
+ def parse(cls, s: str) -> 'DeployPath':
6746
+ check.non_empty_str(s)
6747
+ ps = split_keep_delimiter(s, '/')
6748
+ return cls(tuple(DeployPathPart.parse(p) for p in ps))
6749
+
6750
+
6751
+ ########################################
6752
+ # ../deploy/specs.py
6753
+
6754
+
6755
+ ##
6756
+
6757
+
6758
+ def check_valid_deploy_spec_path(s: str) -> str:
6759
+ check.non_empty_str(s)
6760
+ for c in ['..', '//']:
6761
+ check.not_in(c, s)
6762
+ check.arg(not s.startswith('/'))
6763
+ return s
6764
+
6765
+
6766
+ ##
6767
+
6768
+
6769
+ @dc.dataclass(frozen=True)
6770
+ class DeployGitRepo:
6771
+ host: ta.Optional[str] = None
6772
+ username: ta.Optional[str] = None
6773
+ path: ta.Optional[str] = None
6774
+
6775
+ def __post_init__(self) -> None:
6776
+ check.not_in('..', check.non_empty_str(self.host))
6777
+ check.not_in('.', check.non_empty_str(self.path))
6778
+
6779
+
6780
+ @dc.dataclass(frozen=True)
6781
+ class DeployGitSpec:
6782
+ repo: DeployGitRepo
6783
+ rev: DeployRev
6784
+
6785
+ subtrees: ta.Optional[ta.Sequence[str]] = None
6786
+
6787
+ def __post_init__(self) -> None:
6788
+ check.non_empty_str(self.rev)
6789
+ if self.subtrees is not None:
6790
+ for st in self.subtrees:
6791
+ check.non_empty_str(st)
6792
+
6793
+
6794
+ ##
6795
+
6796
+
6797
+ @dc.dataclass(frozen=True)
6798
+ class DeployVenvSpec:
6799
+ interp: ta.Optional[str] = None
6800
+
6801
+ requirements_files: ta.Optional[ta.Sequence[str]] = None
6802
+ extra_dependencies: ta.Optional[ta.Sequence[str]] = None
6803
+
6804
+ use_uv: bool = False
6805
+
6806
+
6807
+ ##
6808
+
6809
+
6810
+ @dc.dataclass(frozen=True)
6811
+ class DeployConfFile:
6812
+ path: str
6813
+ body: str
6814
+
6815
+ def __post_init__(self) -> None:
6816
+ check_valid_deploy_spec_path(self.path)
6817
+
6818
+
6819
+ #
6820
+
6821
+
6822
+ @dc.dataclass(frozen=True)
6823
+ class DeployConfLink(abc.ABC): # noqa
6824
+ """
6825
+ May be either:
6826
+ - @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
6827
+ - @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
6828
+ - @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
6829
+ """
6830
+
6831
+ src: str
6832
+
6833
+ def __post_init__(self) -> None:
6834
+ check_valid_deploy_spec_path(self.src)
6835
+ if '/' in self.src:
6836
+ check.equal(self.src.count('/'), 1)
6837
+
6838
+
6839
+ class AppDeployConfLink(DeployConfLink):
6840
+ pass
6841
+
6842
+
6843
+ class TagDeployConfLink(DeployConfLink):
6844
+ pass
6845
+
6846
+
6847
+ #
6848
+
6849
+
6850
+ @dc.dataclass(frozen=True)
6851
+ class DeployConfSpec:
6852
+ files: ta.Optional[ta.Sequence[DeployConfFile]] = None
6853
+
6854
+ links: ta.Optional[ta.Sequence[DeployConfLink]] = None
6855
+
6856
+ def __post_init__(self) -> None:
6857
+ if self.files:
6858
+ seen: ta.Set[str] = set()
6859
+ for f in self.files:
6860
+ check.not_in(f.path, seen)
6861
+ seen.add(f.path)
6862
+
6863
+
6864
+ ##
6865
+
6866
+
6867
+ @dc.dataclass(frozen=True)
6868
+ class DeploySpec:
6869
+ app: DeployApp
6870
+
6871
+ git: DeployGitSpec
6872
+
6873
+ venv: ta.Optional[DeployVenvSpec] = None
6874
+
6875
+ conf: ta.Optional[DeployConfSpec] = None
6876
+
6877
+ @cached_nullary
6878
+ def key(self) -> DeployKey:
6879
+ return DeployKey(hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8])
6880
+
6881
+
6882
+ ########################################
6883
+ # ../marshal.py
6884
+
6885
+
6886
+ @dc.dataclass(frozen=True)
6887
+ class ObjMarshalerInstaller:
6888
+ fn: ta.Callable[[ObjMarshalerManager], None]
6889
+
6890
+
6891
+ ObjMarshalerInstallers = ta.NewType('ObjMarshalerInstallers', ta.Sequence[ObjMarshalerInstaller])
6892
+
6893
+
6894
+ ########################################
6895
+ # ../remote/channel.py
6803
6896
 
6804
6897
 
6805
6898
  ##
@@ -7336,38 +7429,253 @@ class AbstractAsyncSubprocesses(BaseSubprocesses):
7336
7429
  return ret.decode().strip()
7337
7430
 
7338
7431
 
7339
- ########################################
7340
- # ../bootstrap.py
7432
+ ########################################
7433
+ # ../bootstrap.py
7434
+
7435
+
7436
+ @dc.dataclass(frozen=True)
7437
+ class MainBootstrap:
7438
+ main_config: MainConfig = MainConfig()
7439
+
7440
+ deploy_config: DeployConfig = DeployConfig()
7441
+
7442
+ remote_config: RemoteConfig = RemoteConfig()
7443
+
7444
+ system_config: SystemConfig = SystemConfig()
7445
+
7446
+
7447
+ ########################################
7448
+ # ../commands/local.py
7449
+
7450
+
7451
+ class LocalCommandExecutor(CommandExecutor):
7452
+ def __init__(
7453
+ self,
7454
+ *,
7455
+ command_executors: CommandExecutorMap,
7456
+ ) -> None:
7457
+ super().__init__()
7458
+
7459
+ self._command_executors = command_executors
7460
+
7461
+ async def execute(self, cmd: Command) -> Command.Output:
7462
+ ce: CommandExecutor = self._command_executors[type(cmd)]
7463
+ return await ce.execute(cmd)
7464
+
7465
+
7466
+ ########################################
7467
+ # ../deploy/conf.py
7468
+ """
7469
+ TODO:
7470
+ - @conf DeployPathPlaceholder? :|
7471
+ - post-deploy: remove any dir_links not present in new spec
7472
+ - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
7473
+ - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
7474
+ - ** whole thing can be atomic **
7475
+ - 1) new atomic temp dir
7476
+ - 2) for each subdir not needing modification, hardlink into temp dir
7477
+ - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
7478
+ - 4) write (or if deleting, omit) new files
7479
+ - 5) swap top level
7480
+ - ** whole deploy can be atomic(-ish) - do this for everything **
7481
+ - just a '/deploy/current' dir
7482
+ - some things (venvs) cannot be moved, thus the /deploy/venvs dir
7483
+ - ** ensure (enforce) equivalent relpath nesting
7484
+ """
7485
+
7486
+
7487
+ class DeployConfManager:
7488
+ def __init__(
7489
+ self,
7490
+ *,
7491
+ deploy_home: ta.Optional[DeployHome] = None,
7492
+ ) -> None:
7493
+ super().__init__()
7494
+
7495
+ self._deploy_home = deploy_home
7496
+
7497
+ #
7498
+
7499
+ async def _write_conf_file(
7500
+ self,
7501
+ cf: DeployConfFile,
7502
+ conf_dir: str,
7503
+ ) -> None:
7504
+ conf_file = os.path.join(conf_dir, cf.path)
7505
+ check.arg(is_path_in_dir(conf_dir, conf_file))
7506
+
7507
+ os.makedirs(os.path.dirname(conf_file), exist_ok=True)
7508
+
7509
+ with open(conf_file, 'w') as f: # noqa
7510
+ f.write(cf.body)
7511
+
7512
+ #
7513
+
7514
+ class _ComputedConfLink(ta.NamedTuple):
7515
+ is_dir: bool
7516
+ link_src: str
7517
+ link_dst: str
7518
+
7519
+ def _compute_conf_link_dst(
7520
+ self,
7521
+ link: DeployConfLink,
7522
+ app_tag: DeployAppTag,
7523
+ conf_dir: str,
7524
+ link_dir: str,
7525
+ ) -> _ComputedConfLink:
7526
+ link_src = os.path.join(conf_dir, link.src)
7527
+ check.arg(is_path_in_dir(conf_dir, link_src))
7528
+
7529
+ #
7530
+
7531
+ if (is_dir := link.src.endswith('/')):
7532
+ # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7533
+ check.arg(link.src.count('/') == 1)
7534
+ link_dst_pfx = link.src
7535
+ link_dst_sfx = ''
7536
+
7537
+ elif '/' in link.src:
7538
+ # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7539
+ d, f = os.path.split(link.src)
7540
+ # TODO: check filename :|
7541
+ link_dst_pfx = d + '/'
7542
+ link_dst_sfx = DEPLOY_PATH_PLACEHOLDER_SEPARATOR + f
7543
+
7544
+ else: # noqa
7545
+ # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7546
+ if '.' in link.src:
7547
+ l, _, r = link.src.partition('.')
7548
+ link_dst_pfx = l + '/'
7549
+ link_dst_sfx = '.' + r
7550
+ else:
7551
+ link_dst_pfx = link.src + '/'
7552
+ link_dst_sfx = ''
7553
+
7554
+ #
7555
+
7556
+ if isinstance(link, AppDeployConfLink):
7557
+ link_dst_mid = str(app_tag.app)
7558
+ elif isinstance(link, TagDeployConfLink):
7559
+ link_dst_mid = DEPLOY_PATH_PLACEHOLDER_SEPARATOR.join([app_tag.app, app_tag.tag])
7560
+ else:
7561
+ raise TypeError(link)
7562
+
7563
+ #
7564
+
7565
+ link_dst_name = ''.join([
7566
+ link_dst_pfx,
7567
+ link_dst_mid,
7568
+ link_dst_sfx,
7569
+ ])
7570
+ link_dst = os.path.join(link_dir, link_dst_name)
7571
+
7572
+ return DeployConfManager._ComputedConfLink(
7573
+ is_dir=is_dir,
7574
+ link_src=link_src,
7575
+ link_dst=link_dst,
7576
+ )
7577
+
7578
+ async def _make_conf_link(
7579
+ self,
7580
+ link: DeployConfLink,
7581
+ app_tag: DeployAppTag,
7582
+ conf_dir: str,
7583
+ link_dir: str,
7584
+ ) -> None:
7585
+ comp = self._compute_conf_link_dst(
7586
+ link,
7587
+ app_tag,
7588
+ conf_dir,
7589
+ link_dir,
7590
+ )
7591
+
7592
+ #
7593
+
7594
+ check.arg(is_path_in_dir(conf_dir, comp.link_src))
7595
+ check.arg(is_path_in_dir(link_dir, comp.link_dst))
7596
+
7597
+ if comp.is_dir:
7598
+ check.arg(os.path.isdir(comp.link_src))
7599
+ else:
7600
+ check.arg(os.path.isfile(comp.link_src))
7601
+
7602
+ #
7341
7603
 
7604
+ relative_symlink( # noqa
7605
+ comp.link_src,
7606
+ comp.link_dst,
7607
+ target_is_directory=comp.is_dir,
7608
+ make_dirs=True,
7609
+ )
7342
7610
 
7343
- @dc.dataclass(frozen=True)
7344
- class MainBootstrap:
7345
- main_config: MainConfig = MainConfig()
7611
+ #
7346
7612
 
7347
- deploy_config: DeployConfig = DeployConfig()
7613
+ async def write_conf(
7614
+ self,
7615
+ spec: DeployConfSpec,
7616
+ app_tag: DeployAppTag,
7617
+ conf_dir: str,
7618
+ link_dir: str,
7619
+ ) -> None:
7620
+ for cf in spec.files or []:
7621
+ await self._write_conf_file(
7622
+ cf,
7623
+ conf_dir,
7624
+ )
7348
7625
 
7349
- remote_config: RemoteConfig = RemoteConfig()
7626
+ #
7350
7627
 
7351
- system_config: SystemConfig = SystemConfig()
7628
+ for link in spec.links or []:
7629
+ await self._make_conf_link(
7630
+ link,
7631
+ app_tag,
7632
+ conf_dir,
7633
+ link_dir,
7634
+ )
7352
7635
 
7353
7636
 
7354
7637
  ########################################
7355
- # ../commands/local.py
7638
+ # ../deploy/paths/owners.py
7356
7639
 
7357
7640
 
7358
- class LocalCommandExecutor(CommandExecutor):
7641
+ class DeployPathOwner(abc.ABC):
7642
+ @abc.abstractmethod
7643
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
7644
+ raise NotImplementedError
7645
+
7646
+
7647
+ DeployPathOwners = ta.NewType('DeployPathOwners', ta.Sequence[DeployPathOwner])
7648
+
7649
+
7650
+ class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
7359
7651
  def __init__(
7360
7652
  self,
7361
- *,
7362
- command_executors: CommandExecutorMap,
7653
+ *args: ta.Any,
7654
+ owned_dir: str,
7655
+ deploy_home: ta.Optional[DeployHome],
7656
+ **kwargs: ta.Any,
7363
7657
  ) -> None:
7364
- super().__init__()
7658
+ super().__init__(*args, **kwargs)
7365
7659
 
7366
- self._command_executors = command_executors
7660
+ check.not_in('/', owned_dir)
7661
+ self._owned_dir: str = check.non_empty_str(owned_dir)
7367
7662
 
7368
- async def execute(self, cmd: Command) -> Command.Output:
7369
- ce: CommandExecutor = self._command_executors[type(cmd)]
7370
- return await ce.execute(cmd)
7663
+ self._deploy_home = deploy_home
7664
+
7665
+ self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
7666
+
7667
+ @cached_nullary
7668
+ def _dir(self) -> str:
7669
+ return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
7670
+
7671
+ @cached_nullary
7672
+ def _make_dir(self) -> str:
7673
+ if not os.path.isdir(d := self._dir()):
7674
+ os.makedirs(d, exist_ok=True)
7675
+ return d
7676
+
7677
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
7678
+ return self._owned_deploy_paths
7371
7679
 
7372
7680
 
7373
7681
  ########################################
@@ -8240,7 +8548,7 @@ class DeployGitManager(SingleDirDeployPathOwner):
8240
8548
 
8241
8549
  #
8242
8550
 
8243
- async def checkout(self, checkout: DeployGitCheckout, dst_dir: str) -> None:
8551
+ async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
8244
8552
  check.state(not os.path.exists(dst_dir))
8245
8553
  with self._git._atomics.begin_atomic_path_swap( # noqa
8246
8554
  'dir',
@@ -8248,14 +8556,14 @@ class DeployGitManager(SingleDirDeployPathOwner):
8248
8556
  auto_commit=True,
8249
8557
  make_dirs=True,
8250
8558
  ) as dst_swap:
8251
- await self.fetch(checkout.rev)
8559
+ await self.fetch(spec.rev)
8252
8560
 
8253
8561
  dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
8254
8562
  await dst_call('git', 'init')
8255
8563
 
8256
8564
  await dst_call('git', 'remote', 'add', 'local', self._dir)
8257
- await dst_call('git', 'fetch', '--depth=1', 'local', checkout.rev)
8258
- await dst_call('git', 'checkout', checkout.rev, *(checkout.subtrees or []))
8565
+ await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
8566
+ await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
8259
8567
 
8260
8568
  def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
8261
8569
  try:
@@ -8264,8 +8572,80 @@ class DeployGitManager(SingleDirDeployPathOwner):
8264
8572
  repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
8265
8573
  return repo_dir
8266
8574
 
8267
- async def checkout(self, checkout: DeployGitCheckout, dst_dir: str) -> None:
8268
- await self.get_repo_dir(checkout.repo).checkout(checkout, dst_dir)
8575
+ async def checkout(
8576
+ self,
8577
+ spec: DeployGitSpec,
8578
+ dst_dir: str,
8579
+ ) -> None:
8580
+ await self.get_repo_dir(spec.repo).checkout(spec, dst_dir)
8581
+
8582
+
8583
+ ########################################
8584
+ # ../deploy/paths/manager.py
8585
+
8586
+
8587
+ class DeployPathsManager:
8588
+ def __init__(
8589
+ self,
8590
+ *,
8591
+ deploy_home: ta.Optional[DeployHome],
8592
+ deploy_path_owners: DeployPathOwners,
8593
+ ) -> None:
8594
+ super().__init__()
8595
+
8596
+ self._deploy_home = deploy_home
8597
+ self._deploy_path_owners = deploy_path_owners
8598
+
8599
+ @cached_nullary
8600
+ def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
8601
+ dct: ta.Dict[DeployPath, DeployPathOwner] = {}
8602
+ for o in self._deploy_path_owners:
8603
+ for p in o.get_owned_deploy_paths():
8604
+ if p in dct:
8605
+ raise DeployPathError(f'Duplicate deploy path owner: {p}')
8606
+ dct[p] = o
8607
+ return dct
8608
+
8609
+ def validate_deploy_paths(self) -> None:
8610
+ self.owners_by_path()
8611
+
8612
+
8613
+ ########################################
8614
+ # ../deploy/tmp.py
8615
+
8616
+
8617
+ class DeployTmpManager(
8618
+ SingleDirDeployPathOwner,
8619
+ AtomicPathSwapping,
8620
+ ):
8621
+ def __init__(
8622
+ self,
8623
+ *,
8624
+ deploy_home: ta.Optional[DeployHome] = None,
8625
+ ) -> None:
8626
+ super().__init__(
8627
+ owned_dir='tmp',
8628
+ deploy_home=deploy_home,
8629
+ )
8630
+
8631
+ @cached_nullary
8632
+ def _swapping(self) -> AtomicPathSwapping:
8633
+ return TempDirAtomicPathSwapping(
8634
+ temp_dir=self._make_dir(),
8635
+ root_dir=check.non_empty_str(self._deploy_home),
8636
+ )
8637
+
8638
+ def begin_atomic_path_swap(
8639
+ self,
8640
+ kind: AtomicPathSwapKind,
8641
+ dst_path: str,
8642
+ **kwargs: ta.Any,
8643
+ ) -> AtomicPathSwap:
8644
+ return self._swapping().begin_atomic_path_swap(
8645
+ kind,
8646
+ dst_path,
8647
+ **kwargs,
8648
+ )
8269
8649
 
8270
8650
 
8271
8651
  ########################################
@@ -8277,32 +8657,21 @@ TODO:
8277
8657
  """
8278
8658
 
8279
8659
 
8280
- class DeployVenvManager(DeployPathOwner):
8660
+ class DeployVenvManager:
8281
8661
  def __init__(
8282
8662
  self,
8283
8663
  *,
8284
- deploy_home: ta.Optional[DeployHome] = None,
8285
8664
  atomics: AtomicPathSwapping,
8286
8665
  ) -> None:
8287
8666
  super().__init__()
8288
8667
 
8289
- self._deploy_home = deploy_home
8290
8668
  self._atomics = atomics
8291
8669
 
8292
- @cached_nullary
8293
- def _dir(self) -> str:
8294
- return os.path.join(check.non_empty_str(self._deploy_home), 'venvs')
8295
-
8296
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8297
- return {
8298
- DeployPath.parse('venvs/@app/@tag/'),
8299
- }
8300
-
8301
8670
  async def setup_venv(
8302
8671
  self,
8303
- app_dir: str,
8304
- venv_dir: str,
8305
8672
  spec: DeployVenvSpec,
8673
+ git_dir: str,
8674
+ venv_dir: str,
8306
8675
  ) -> None:
8307
8676
  sys_exe = 'python3'
8308
8677
 
@@ -8316,7 +8685,7 @@ class DeployVenvManager(DeployPathOwner):
8316
8685
 
8317
8686
  #
8318
8687
 
8319
- reqs_txt = os.path.join(app_dir, 'requirements.txt')
8688
+ reqs_txt = os.path.join(git_dir, 'requirements.txt')
8320
8689
 
8321
8690
  if os.path.isfile(reqs_txt):
8322
8691
  if spec.use_uv:
@@ -8327,17 +8696,6 @@ class DeployVenvManager(DeployPathOwner):
8327
8696
 
8328
8697
  await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
8329
8698
 
8330
- async def setup_app_venv(
8331
- self,
8332
- app_tag: DeployAppTag,
8333
- spec: DeployVenvSpec,
8334
- ) -> None:
8335
- await self.setup_venv(
8336
- os.path.join(check.non_empty_str(self._deploy_home), 'apps', app_tag.app, app_tag.tag),
8337
- os.path.join(self._dir(), app_tag.app, app_tag.tag),
8338
- spec,
8339
- )
8340
-
8341
8699
 
8342
8700
  ########################################
8343
8701
  # ../remote/_main.py
@@ -8886,42 +9244,156 @@ class DeployAppManager(DeployPathOwner):
8886
9244
  self,
8887
9245
  *,
8888
9246
  deploy_home: ta.Optional[DeployHome] = None,
9247
+
9248
+ conf: DeployConfManager,
8889
9249
  git: DeployGitManager,
8890
9250
  venvs: DeployVenvManager,
8891
9251
  ) -> None:
8892
9252
  super().__init__()
8893
9253
 
8894
9254
  self._deploy_home = deploy_home
9255
+
9256
+ self._conf = conf
8895
9257
  self._git = git
8896
9258
  self._venvs = venvs
8897
9259
 
8898
- @cached_nullary
8899
- def _dir(self) -> str:
8900
- return os.path.join(check.non_empty_str(self._deploy_home), 'apps')
9260
+ #
9261
+
9262
+ _APP_TAG_DIR_STR = 'tags/apps/@app/@tag/'
9263
+ _APP_TAG_DIR = DeployPath.parse(_APP_TAG_DIR_STR)
9264
+
9265
+ _CONF_TAG_DIR_STR = 'tags/conf/@tag--@app/'
9266
+ _CONF_TAG_DIR = DeployPath.parse(_CONF_TAG_DIR_STR)
8901
9267
 
9268
+ _DEPLOY_DIR_STR = 'deploys/@tag--@app/'
9269
+ _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
9270
+
9271
+ _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
9272
+ _CONF_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}conf')
9273
+
9274
+ @cached_nullary
8902
9275
  def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8903
9276
  return {
8904
- DeployPath.parse('apps/@app/@tag/'),
9277
+ self._APP_TAG_DIR,
9278
+
9279
+ self._CONF_TAG_DIR,
9280
+
9281
+ self._DEPLOY_DIR,
9282
+
9283
+ self._APP_DEPLOY_LINK,
9284
+ self._CONF_DEPLOY_LINK,
9285
+
9286
+ *[
9287
+ DeployPath.parse(f'{self._APP_TAG_DIR_STR}{sfx}/')
9288
+ for sfx in [
9289
+ 'conf',
9290
+ 'git',
9291
+ 'venv',
9292
+ ]
9293
+ ],
8905
9294
  }
8906
9295
 
9296
+ #
9297
+
8907
9298
  async def prepare_app(
8908
9299
  self,
8909
9300
  spec: DeploySpec,
8910
9301
  ) -> None:
8911
- app_tag = DeployAppTag(spec.app, make_deploy_tag(spec.checkout.rev, spec.key()))
8912
- app_dir = os.path.join(self._dir(), spec.app, app_tag.tag)
9302
+ app_tag = DeployAppTag(spec.app, make_deploy_tag(spec.git.rev, spec.key()))
9303
+
9304
+ #
9305
+
9306
+ deploy_home = check.non_empty_str(self._deploy_home)
9307
+
9308
+ def build_path(pth: DeployPath) -> str:
9309
+ return os.path.join(deploy_home, pth.render(app_tag.placeholders()))
9310
+
9311
+ app_tag_dir = build_path(self._APP_TAG_DIR)
9312
+ conf_tag_dir = build_path(self._CONF_TAG_DIR)
9313
+ deploy_dir = build_path(self._DEPLOY_DIR)
9314
+ app_deploy_link = build_path(self._APP_DEPLOY_LINK)
9315
+ conf_deploy_link_file = build_path(self._CONF_DEPLOY_LINK)
9316
+
9317
+ #
9318
+
9319
+ os.makedirs(deploy_dir)
9320
+
9321
+ deploying_link = os.path.join(deploy_home, 'deploys/deploying')
9322
+ relative_symlink(
9323
+ deploy_dir,
9324
+ deploying_link,
9325
+ target_is_directory=True,
9326
+ make_dirs=True,
9327
+ )
9328
+
9329
+ #
9330
+
9331
+ os.makedirs(app_tag_dir)
9332
+ relative_symlink(
9333
+ app_tag_dir,
9334
+ app_deploy_link,
9335
+ target_is_directory=True,
9336
+ make_dirs=True,
9337
+ )
9338
+
9339
+ #
9340
+
9341
+ os.makedirs(conf_tag_dir)
9342
+ relative_symlink(
9343
+ conf_tag_dir,
9344
+ conf_deploy_link_file,
9345
+ target_is_directory=True,
9346
+ make_dirs=True,
9347
+ )
8913
9348
 
8914
9349
  #
8915
9350
 
9351
+ git_dir = os.path.join(app_tag_dir, 'git')
8916
9352
  await self._git.checkout(
8917
- spec.checkout,
8918
- app_dir,
9353
+ spec.git,
9354
+ git_dir,
8919
9355
  )
8920
9356
 
8921
9357
  #
8922
9358
 
8923
9359
  if spec.venv is not None:
8924
- await self._venvs.setup_app_venv(app_tag, spec.venv)
9360
+ venv_dir = os.path.join(app_tag_dir, 'venv')
9361
+ await self._venvs.setup_venv(
9362
+ spec.venv,
9363
+ git_dir,
9364
+ venv_dir,
9365
+ )
9366
+
9367
+ #
9368
+
9369
+ if spec.conf is not None:
9370
+ conf_dir = os.path.join(app_tag_dir, 'conf')
9371
+ await self._conf.write_conf(
9372
+ spec.conf,
9373
+ app_tag,
9374
+ conf_dir,
9375
+ conf_tag_dir,
9376
+ )
9377
+
9378
+ #
9379
+
9380
+ current_link = os.path.join(deploy_home, 'deploys/current')
9381
+ os.replace(deploying_link, current_link)
9382
+
9383
+
9384
+ ########################################
9385
+ # ../deploy/paths/inject.py
9386
+
9387
+
9388
+ def bind_deploy_paths() -> InjectorBindings:
9389
+ lst: ta.List[InjectorBindingOrBindings] = [
9390
+ inj.bind_array(DeployPathOwner),
9391
+ inj.bind_array_type(DeployPathOwner, DeployPathOwners),
9392
+
9393
+ inj.bind(DeployPathsManager, singleton=True),
9394
+ ]
9395
+
9396
+ return inj.as_bindings(*lst)
8925
9397
 
8926
9398
 
8927
9399
  ########################################
@@ -9639,31 +10111,30 @@ class SystemInterpProvider(InterpProvider):
9639
10111
 
9640
10112
 
9641
10113
  ########################################
9642
- # ../deploy/commands.py
9643
-
9644
-
9645
- ##
9646
-
9647
-
9648
- @dc.dataclass(frozen=True)
9649
- class DeployCommand(Command['DeployCommand.Output']):
9650
- spec: DeploySpec
10114
+ # ../deploy/deploy.py
9651
10115
 
9652
- @dc.dataclass(frozen=True)
9653
- class Output(Command.Output):
9654
- pass
9655
10116
 
10117
+ class DeployManager:
10118
+ def __init__(
10119
+ self,
10120
+ *,
10121
+ apps: DeployAppManager,
10122
+ paths: DeployPathsManager,
10123
+ ):
10124
+ super().__init__()
9656
10125
 
9657
- @dc.dataclass(frozen=True)
9658
- class DeployCommandExecutor(CommandExecutor[DeployCommand, DeployCommand.Output]):
9659
- _apps: DeployAppManager
10126
+ self._apps = apps
10127
+ self._paths = paths
9660
10128
 
9661
- async def execute(self, cmd: DeployCommand) -> DeployCommand.Output:
9662
- log.info('Deploying! %r', cmd.spec)
10129
+ async def run_deploy(
10130
+ self,
10131
+ spec: DeploySpec,
10132
+ ) -> None:
10133
+ self._paths.validate_deploy_paths()
9663
10134
 
9664
- await self._apps.prepare_app(cmd.spec)
10135
+ #
9665
10136
 
9666
- return DeployCommand.Output()
10137
+ await self._apps.prepare_app(spec)
9667
10138
 
9668
10139
 
9669
10140
  ########################################
@@ -9974,6 +10445,34 @@ DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
9974
10445
  ]])
9975
10446
 
9976
10447
 
10448
+ ########################################
10449
+ # ../deploy/commands.py
10450
+
10451
+
10452
+ ##
10453
+
10454
+
10455
+ @dc.dataclass(frozen=True)
10456
+ class DeployCommand(Command['DeployCommand.Output']):
10457
+ spec: DeploySpec
10458
+
10459
+ @dc.dataclass(frozen=True)
10460
+ class Output(Command.Output):
10461
+ pass
10462
+
10463
+
10464
+ @dc.dataclass(frozen=True)
10465
+ class DeployCommandExecutor(CommandExecutor[DeployCommand, DeployCommand.Output]):
10466
+ _deploy: DeployManager
10467
+
10468
+ async def execute(self, cmd: DeployCommand) -> DeployCommand.Output:
10469
+ log.info('Deploying! %r', cmd.spec)
10470
+
10471
+ await self._deploy.run_deploy(cmd.spec)
10472
+
10473
+ return DeployCommand.Output()
10474
+
10475
+
9977
10476
  ########################################
9978
10477
  # ../targets/inject.py
9979
10478
 
@@ -10044,22 +10543,43 @@ def bind_deploy(
10044
10543
  lst: ta.List[InjectorBindingOrBindings] = [
10045
10544
  inj.bind(deploy_config),
10046
10545
 
10047
- #
10546
+ bind_deploy_paths(),
10547
+ ]
10548
+
10549
+ #
10048
10550
 
10049
- inj.bind(DeployAppManager, singleton=True),
10551
+ def bind_manager(cls: type) -> InjectorBindings:
10552
+ return inj.as_bindings(
10553
+ inj.bind(cls, singleton=True),
10050
10554
 
10051
- inj.bind(DeployGitManager, singleton=True),
10555
+ *([inj.bind(DeployPathOwner, to_key=cls, array=True)] if issubclass(cls, DeployPathOwner) else []),
10556
+ )
10557
+
10558
+ #
10559
+
10560
+ lst.extend([
10561
+ bind_manager(DeployAppManager),
10562
+
10563
+ bind_manager(DeployConfManager),
10564
+
10565
+ bind_manager(DeployGitManager),
10566
+
10567
+ bind_manager(DeployManager),
10052
10568
 
10053
- inj.bind(DeployTmpManager, singleton=True),
10569
+ bind_manager(DeployTmpManager),
10054
10570
  inj.bind(AtomicPathSwapping, to_key=DeployTmpManager),
10055
10571
 
10056
- inj.bind(DeployVenvManager, singleton=True),
10572
+ bind_manager(DeployVenvManager),
10573
+ ])
10057
10574
 
10058
- #
10575
+ #
10059
10576
 
10577
+ lst.extend([
10060
10578
  bind_command(DeployCommand, DeployCommandExecutor),
10061
10579
  bind_command(InterpCommand, InterpCommandExecutor),
10062
- ]
10580
+ ])
10581
+
10582
+ #
10063
10583
 
10064
10584
  if (dh := deploy_config.deploy_home) is not None:
10065
10585
  dh = os.path.abspath(os.path.expanduser(dh))