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

Sign up to get free protection for your applications and to get access to all the features.
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))