ominfra 0.0.0.dev175__py3-none-any.whl → 0.0.0.dev176__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
@@ -129,12 +129,12 @@ AtomicPathSwapState = ta.Literal['open', 'committed', 'aborted'] # ta.TypeAlias
129
129
  # ../configs.py
130
130
  ConfigMapping = ta.Mapping[str, ta.Any]
131
131
 
132
- # deploy/specs.py
133
- KeyDeployTagT = ta.TypeVar('KeyDeployTagT', bound='KeyDeployTag')
134
-
135
132
  # ../../omlish/subprocesses.py
136
133
  SubprocessChannelOption = ta.Literal['pipe', 'stdout', 'devnull'] # ta.TypeAlias
137
134
 
135
+ # deploy/specs.py
136
+ KeyDeployTagT = ta.TypeVar('KeyDeployTagT', bound='KeyDeployTag')
137
+
138
138
  # system/packages.py
139
139
  SystemPackageOrStr = ta.Union['SystemPackage', str]
140
140
 
@@ -4251,229 +4251,6 @@ def build_command_name_map(crs: CommandRegistrations) -> CommandNameMap:
4251
4251
  return CommandNameMap(dct)
4252
4252
 
4253
4253
 
4254
- ########################################
4255
- # ../deploy/tags.py
4256
-
4257
-
4258
- ##
4259
-
4260
-
4261
- DEPLOY_TAG_SIGIL = '@'
4262
-
4263
- DEPLOY_TAG_SEPARATOR = '--'
4264
-
4265
- DEPLOY_TAG_DELIMITERS: ta.AbstractSet[str] = frozenset([
4266
- DEPLOY_TAG_SEPARATOR,
4267
- '.',
4268
- ])
4269
-
4270
- DEPLOY_TAG_ILLEGAL_STRS: ta.AbstractSet[str] = frozenset([
4271
- DEPLOY_TAG_SIGIL,
4272
- *DEPLOY_TAG_DELIMITERS,
4273
- '/',
4274
- ])
4275
-
4276
-
4277
- ##
4278
-
4279
-
4280
- @dc.dataclass(frozen=True)
4281
- class DeployTag(abc.ABC): # noqa
4282
- s: str
4283
-
4284
- def __post_init__(self) -> None:
4285
- check.not_in(abc.ABC, type(self).__bases__)
4286
- check.non_empty_str(self.s)
4287
- for ch in DEPLOY_TAG_ILLEGAL_STRS:
4288
- check.state(ch not in self.s)
4289
-
4290
- #
4291
-
4292
- tag_name: ta.ClassVar[str]
4293
- tag_kwarg: ta.ClassVar[str]
4294
-
4295
- def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4296
- super().__init_subclass__(**kwargs)
4297
-
4298
- if abc.ABC in cls.__bases__:
4299
- return
4300
-
4301
- for b in cls.__bases__:
4302
- if issubclass(b, DeployTag):
4303
- check.in_(abc.ABC, b.__bases__)
4304
-
4305
- check.non_empty_str(tn := cls.tag_name)
4306
- check.equal(tn, tn.lower().strip())
4307
- check.not_in('_', tn)
4308
-
4309
- check.state(not hasattr(cls, 'tag_kwarg'))
4310
- cls.tag_kwarg = tn.replace('-', '_')
4311
-
4312
-
4313
- ##
4314
-
4315
-
4316
- _DEPLOY_TAGS: ta.Set[ta.Type[DeployTag]] = set()
4317
- DEPLOY_TAGS: ta.AbstractSet[ta.Type[DeployTag]] = _DEPLOY_TAGS
4318
-
4319
- _DEPLOY_TAGS_BY_NAME: ta.Dict[str, ta.Type[DeployTag]] = {}
4320
- DEPLOY_TAGS_BY_NAME: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_NAME
4321
-
4322
- _DEPLOY_TAGS_BY_KWARG: ta.Dict[str, ta.Type[DeployTag]] = {}
4323
- DEPLOY_TAGS_BY_KWARG: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_KWARG
4324
-
4325
-
4326
- def _register_deploy_tag(cls):
4327
- check.not_in(cls.tag_name, _DEPLOY_TAGS_BY_NAME)
4328
- check.not_in(cls.tag_kwarg, _DEPLOY_TAGS_BY_KWARG)
4329
-
4330
- _DEPLOY_TAGS.add(cls)
4331
- _DEPLOY_TAGS_BY_NAME[cls.tag_name] = cls
4332
- _DEPLOY_TAGS_BY_KWARG[cls.tag_kwarg] = cls
4333
-
4334
- return cls
4335
-
4336
-
4337
- ##
4338
-
4339
-
4340
- @_register_deploy_tag
4341
- class DeployTime(DeployTag):
4342
- tag_name: ta.ClassVar[str] = 'time'
4343
-
4344
-
4345
- ##
4346
-
4347
-
4348
- class NameDeployTag(DeployTag, abc.ABC): # noqa
4349
- pass
4350
-
4351
-
4352
- @_register_deploy_tag
4353
- class DeployApp(NameDeployTag):
4354
- tag_name: ta.ClassVar[str] = 'app'
4355
-
4356
-
4357
- @_register_deploy_tag
4358
- class DeployConf(NameDeployTag):
4359
- tag_name: ta.ClassVar[str] = 'conf'
4360
-
4361
-
4362
- ##
4363
-
4364
-
4365
- class KeyDeployTag(DeployTag, abc.ABC): # noqa
4366
- pass
4367
-
4368
-
4369
- @_register_deploy_tag
4370
- class DeployKey(KeyDeployTag):
4371
- tag_name: ta.ClassVar[str] = 'deploy-key'
4372
-
4373
-
4374
- @_register_deploy_tag
4375
- class DeployAppKey(KeyDeployTag):
4376
- tag_name: ta.ClassVar[str] = 'app-key'
4377
-
4378
-
4379
- ##
4380
-
4381
-
4382
- class RevDeployTag(DeployTag, abc.ABC): # noqa
4383
- pass
4384
-
4385
-
4386
- @_register_deploy_tag
4387
- class DeployAppRev(RevDeployTag):
4388
- tag_name: ta.ClassVar[str] = 'app-rev'
4389
-
4390
-
4391
- ##
4392
-
4393
-
4394
- class DeployTagMap:
4395
- def __init__(
4396
- self,
4397
- *args: DeployTag,
4398
- **kwargs: str,
4399
- ) -> None:
4400
- super().__init__()
4401
-
4402
- dct: ta.Dict[ta.Type[DeployTag], DeployTag] = {}
4403
-
4404
- for a in args:
4405
- c = type(check.isinstance(a, DeployTag))
4406
- check.not_in(c, dct)
4407
- dct[c] = a
4408
-
4409
- for k, v in kwargs.items():
4410
- c = DEPLOY_TAGS_BY_KWARG[k]
4411
- check.not_in(c, dct)
4412
- dct[c] = c(v)
4413
-
4414
- self._dct = dct
4415
- self._tup = tuple(sorted((type(t).tag_kwarg, t.s) for t in dct.values()))
4416
-
4417
- #
4418
-
4419
- def add(self, *args: ta.Any, **kwargs: ta.Any) -> 'DeployTagMap':
4420
- return DeployTagMap(
4421
- *self,
4422
- *args,
4423
- **kwargs,
4424
- )
4425
-
4426
- def remove(self, *tags_or_names: ta.Union[ta.Type[DeployTag], str]) -> 'DeployTagMap':
4427
- dcs = {
4428
- check.issubclass(a, DeployTag) if isinstance(a, type) else DEPLOY_TAGS_BY_NAME[a]
4429
- for a in tags_or_names
4430
- }
4431
-
4432
- return DeployTagMap(*[
4433
- t
4434
- for t in self._dct.values()
4435
- if t not in dcs
4436
- ])
4437
-
4438
- #
4439
-
4440
- def __repr__(self) -> str:
4441
- return f'{self.__class__.__name__}({", ".join(f"{k}={v!r}" for k, v in self._tup)})'
4442
-
4443
- def __hash__(self) -> int:
4444
- return hash(self._tup)
4445
-
4446
- def __eq__(self, other: object) -> bool:
4447
- if isinstance(other, DeployTagMap):
4448
- return self._tup == other._tup
4449
- else:
4450
- return NotImplemented
4451
-
4452
- #
4453
-
4454
- def __len__(self) -> int:
4455
- return len(self._dct)
4456
-
4457
- def __iter__(self) -> ta.Iterator[DeployTag]:
4458
- return iter(self._dct.values())
4459
-
4460
- def __getitem__(self, key: ta.Union[ta.Type[DeployTag], str]) -> DeployTag:
4461
- if isinstance(key, str):
4462
- return self._dct[DEPLOY_TAGS_BY_NAME[key]]
4463
- elif isinstance(key, type):
4464
- return self._dct[key]
4465
- else:
4466
- raise TypeError(key)
4467
-
4468
- def __contains__(self, key: ta.Union[ta.Type[DeployTag], str]) -> bool:
4469
- if isinstance(key, str):
4470
- return DEPLOY_TAGS_BY_NAME[key] in self._dct
4471
- elif isinstance(key, type):
4472
- return key in self._dct
4473
- else:
4474
- raise TypeError(key)
4475
-
4476
-
4477
4254
  ########################################
4478
4255
  # ../remote/config.py
4479
4256
 
@@ -6135,6 +5912,18 @@ class FieldsObjMarshaler(ObjMarshaler):
6135
5912
  })
6136
5913
 
6137
5914
 
5915
+ @dc.dataclass(frozen=True)
5916
+ class SingleFieldObjMarshaler(ObjMarshaler):
5917
+ ty: type
5918
+ fld: str
5919
+
5920
+ def marshal(self, o: ta.Any, ctx: 'ObjMarshalContext') -> ta.Any:
5921
+ return getattr(o, self.fld)
5922
+
5923
+ def unmarshal(self, o: ta.Any, ctx: 'ObjMarshalContext') -> ta.Any:
5924
+ return self.ty(**{self.fld: o})
5925
+
5926
+
6138
5927
  @dc.dataclass(frozen=True)
6139
5928
  class PolymorphicObjMarshaler(ObjMarshaler):
6140
5929
  class Impl(ta.NamedTuple):
@@ -6209,7 +5998,7 @@ _DEFAULT_OBJ_MARSHALERS: ta.Dict[ta.Any, ObjMarshaler] = {
6209
5998
  **{t: IterableObjMarshaler(t, DynamicObjMarshaler()) for t in (list, tuple, set, frozenset)},
6210
5999
  **{t: MappingObjMarshaler(t, DynamicObjMarshaler(), DynamicObjMarshaler()) for t in (dict,)},
6211
6000
 
6212
- ta.Any: DynamicObjMarshaler(),
6001
+ **{t: DynamicObjMarshaler() for t in (ta.Any, object)},
6213
6002
 
6214
6003
  **{t: DatetimeObjMarshaler(t) for t in (datetime.date, datetime.time, datetime.datetime)},
6215
6004
  decimal.Decimal: DecimalObjMarshaler(),
@@ -6234,6 +6023,16 @@ _OBJ_MARSHALER_GENERIC_ITERABLE_TYPES: ta.Dict[ta.Any, type] = {
6234
6023
  ##
6235
6024
 
6236
6025
 
6026
+ _REGISTERED_OBJ_MARSHALERS_BY_TYPE: ta.MutableMapping[type, ObjMarshaler] = weakref.WeakKeyDictionary()
6027
+
6028
+
6029
+ def register_type_obj_marshaler(ty: type, om: ObjMarshaler) -> None:
6030
+ _REGISTERED_OBJ_MARSHALERS_BY_TYPE[ty] = om
6031
+
6032
+
6033
+ ##
6034
+
6035
+
6237
6036
  class ObjMarshalerManager:
6238
6037
  def __init__(
6239
6038
  self,
@@ -6243,6 +6042,8 @@ class ObjMarshalerManager:
6243
6042
  default_obj_marshalers: ta.Dict[ta.Any, ObjMarshaler] = _DEFAULT_OBJ_MARSHALERS, # noqa
6244
6043
  generic_mapping_types: ta.Dict[ta.Any, type] = _OBJ_MARSHALER_GENERIC_MAPPING_TYPES, # noqa
6245
6044
  generic_iterable_types: ta.Dict[ta.Any, type] = _OBJ_MARSHALER_GENERIC_ITERABLE_TYPES, # noqa
6045
+
6046
+ registered_obj_marshalers: ta.Mapping[type, ObjMarshaler] = _REGISTERED_OBJ_MARSHALERS_BY_TYPE,
6246
6047
  ) -> None:
6247
6048
  super().__init__()
6248
6049
 
@@ -6251,6 +6052,7 @@ class ObjMarshalerManager:
6251
6052
  self._obj_marshalers = dict(default_obj_marshalers)
6252
6053
  self._generic_mapping_types = generic_mapping_types
6253
6054
  self._generic_iterable_types = generic_iterable_types
6055
+ self._registered_obj_marshalers = registered_obj_marshalers
6254
6056
 
6255
6057
  self._lock = threading.RLock()
6256
6058
  self._marshalers: ta.Dict[ta.Any, ObjMarshaler] = dict(_DEFAULT_OBJ_MARSHALERS)
@@ -6266,6 +6068,9 @@ class ObjMarshalerManager:
6266
6068
  non_strict_fields: bool = False,
6267
6069
  ) -> ObjMarshaler:
6268
6070
  if isinstance(ty, type):
6071
+ if (reg := self._registered_obj_marshalers.get(ty)) is not None:
6072
+ return reg
6073
+
6269
6074
  if abc.ABC in ty.__bases__:
6270
6075
  impls = [ity for ity in deep_subclasses(ty) if abc.ABC not in ity.__bases__] # type: ignore
6271
6076
  if all(ity.__qualname__.endswith(ty.__name__) for ity in impls):
@@ -6330,9 +6135,15 @@ class ObjMarshalerManager:
6330
6135
 
6331
6136
  #
6332
6137
 
6333
- def register_opj_marshaler(self, ty: ta.Any, m: ObjMarshaler) -> None:
6138
+ def set_obj_marshaler(
6139
+ self,
6140
+ ty: ta.Any,
6141
+ m: ObjMarshaler,
6142
+ *,
6143
+ override: bool = False,
6144
+ ) -> None:
6334
6145
  with self._lock:
6335
- if ty in self._obj_marshalers:
6146
+ if not override and ty in self._obj_marshalers:
6336
6147
  raise KeyError(ty)
6337
6148
  self._obj_marshalers[ty] = m
6338
6149
 
@@ -6423,7 +6234,7 @@ class ObjMarshalContext:
6423
6234
 
6424
6235
  OBJ_MARSHALER_MANAGER = ObjMarshalerManager()
6425
6236
 
6426
- register_opj_marshaler = OBJ_MARSHALER_MANAGER.register_opj_marshaler
6237
+ set_obj_marshaler = OBJ_MARSHALER_MANAGER.set_obj_marshaler
6427
6238
  get_obj_marshaler = OBJ_MARSHALER_MANAGER.get_obj_marshaler
6428
6239
 
6429
6240
  marshal_obj = OBJ_MARSHALER_MANAGER.marshal_obj
@@ -6825,6 +6636,7 @@ def read_config_file(
6825
6636
  cls: ta.Type[T],
6826
6637
  *,
6827
6638
  prepare: ta.Optional[ta.Callable[[ConfigMapping], ConfigMapping]] = None,
6639
+ msh: ObjMarshalerManager = OBJ_MARSHALER_MANAGER,
6828
6640
  ) -> T:
6829
6641
  with open(path) as cf:
6830
6642
  config_dct = parse_config_file(os.path.basename(path), cf)
@@ -6832,7 +6644,7 @@ def read_config_file(
6832
6644
  if prepare is not None:
6833
6645
  config_dct = prepare(config_dct)
6834
6646
 
6835
- return unmarshal_obj(config_dct, cls)
6647
+ return msh.unmarshal_obj(config_dct, cls)
6836
6648
 
6837
6649
 
6838
6650
  def build_config_named_children(
@@ -6885,7 +6697,7 @@ def install_command_marshaling(
6885
6697
  lambda c: c,
6886
6698
  lambda c: c.Output,
6887
6699
  ]:
6888
- msh.register_opj_marshaler(
6700
+ msh.set_obj_marshaler(
6889
6701
  fn(Command),
6890
6702
  PolymorphicObjMarshaler.of([
6891
6703
  PolymorphicObjMarshaler.Impl(
@@ -6927,355 +6739,228 @@ CommandExecutorMap = ta.NewType('CommandExecutorMap', ta.Mapping[ta.Type[Command
6927
6739
 
6928
6740
 
6929
6741
  ########################################
6930
- # ../deploy/paths/paths.py
6931
- """
6932
- TODO:
6933
- - run/{.pid,.sock}
6934
- - logs/...
6935
- - current symlink
6936
- - conf/{nginx,supervisor}
6937
- - env/?
6938
- - apps/<app>/shared
6939
- """
6742
+ # ../deploy/tags.py
6940
6743
 
6941
6744
 
6942
6745
  ##
6943
6746
 
6944
6747
 
6945
- class DeployPathError(Exception):
6946
- pass
6947
-
6748
+ DEPLOY_TAG_SIGIL = '@'
6948
6749
 
6949
- class DeployPathRenderable(abc.ABC):
6950
- @abc.abstractmethod
6951
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6952
- raise NotImplementedError
6750
+ DEPLOY_TAG_SEPARATOR = '--'
6953
6751
 
6752
+ DEPLOY_TAG_DELIMITERS: ta.AbstractSet[str] = frozenset([
6753
+ DEPLOY_TAG_SEPARATOR,
6754
+ '.',
6755
+ ])
6954
6756
 
6955
- ##
6757
+ DEPLOY_TAG_ILLEGAL_STRS: ta.AbstractSet[str] = frozenset([
6758
+ DEPLOY_TAG_SIGIL,
6759
+ *DEPLOY_TAG_DELIMITERS,
6760
+ '/',
6761
+ ])
6956
6762
 
6957
6763
 
6958
- class DeployPathNamePart(DeployPathRenderable, abc.ABC):
6959
- @classmethod
6960
- def parse(cls, s: str) -> 'DeployPathNamePart':
6961
- check.non_empty_str(s)
6962
- if s.startswith(DEPLOY_TAG_SIGIL):
6963
- return TagDeployPathNamePart(s[1:])
6964
- elif s in DEPLOY_TAG_DELIMITERS:
6965
- return DelimiterDeployPathNamePart(s)
6966
- else:
6967
- return ConstDeployPathNamePart(s)
6764
+ ##
6968
6765
 
6969
6766
 
6970
6767
  @dc.dataclass(frozen=True)
6971
- class TagDeployPathNamePart(DeployPathNamePart):
6972
- name: str
6768
+ class DeployTag(abc.ABC): # noqa
6769
+ s: str
6973
6770
 
6974
6771
  def __post_init__(self) -> None:
6975
- check.in_(self.name, DEPLOY_TAGS_BY_NAME)
6772
+ check.not_in(abc.ABC, type(self).__bases__)
6773
+ check.non_empty_str(self.s)
6774
+ for ch in DEPLOY_TAG_ILLEGAL_STRS:
6775
+ check.state(ch not in self.s)
6976
6776
 
6977
- @property
6978
- def tag(self) -> ta.Type[DeployTag]:
6979
- return DEPLOY_TAGS_BY_NAME[self.name]
6777
+ #
6980
6778
 
6981
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6982
- if tags is not None:
6983
- return tags[self.tag].s
6984
- else:
6985
- return DEPLOY_TAG_SIGIL + self.name
6779
+ tag_name: ta.ClassVar[str]
6780
+ tag_kwarg: ta.ClassVar[str]
6986
6781
 
6782
+ def __init_subclass__(cls, **kwargs: ta.Any) -> None:
6783
+ super().__init_subclass__(**kwargs)
6987
6784
 
6988
- @dc.dataclass(frozen=True)
6989
- class DelimiterDeployPathNamePart(DeployPathNamePart):
6990
- delimiter: str
6785
+ if abc.ABC in cls.__bases__:
6786
+ return
6991
6787
 
6992
- def __post_init__(self) -> None:
6993
- check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
6788
+ for b in cls.__bases__:
6789
+ if issubclass(b, DeployTag):
6790
+ check.in_(abc.ABC, b.__bases__)
6994
6791
 
6995
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6996
- return self.delimiter
6792
+ check.non_empty_str(tn := cls.tag_name)
6793
+ check.equal(tn, tn.lower().strip())
6794
+ check.not_in('_', tn)
6997
6795
 
6796
+ check.state(not hasattr(cls, 'tag_kwarg'))
6797
+ cls.tag_kwarg = tn.replace('-', '_')
6998
6798
 
6999
- @dc.dataclass(frozen=True)
7000
- class ConstDeployPathNamePart(DeployPathNamePart):
7001
- const: str
7002
6799
 
7003
- def __post_init__(self) -> None:
7004
- check.non_empty_str(self.const)
7005
- for c in [*DEPLOY_TAG_DELIMITERS, DEPLOY_TAG_SIGIL, '/']:
7006
- check.not_in(c, self.const)
6800
+ ##
7007
6801
 
7008
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7009
- return self.const
7010
6802
 
6803
+ _DEPLOY_TAGS: ta.Set[ta.Type[DeployTag]] = set()
6804
+ DEPLOY_TAGS: ta.AbstractSet[ta.Type[DeployTag]] = _DEPLOY_TAGS
7011
6805
 
7012
- @dc.dataclass(frozen=True)
7013
- class DeployPathName(DeployPathRenderable):
7014
- parts: ta.Sequence[DeployPathNamePart]
6806
+ _DEPLOY_TAGS_BY_NAME: ta.Dict[str, ta.Type[DeployTag]] = {}
6807
+ DEPLOY_TAGS_BY_NAME: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_NAME
7015
6808
 
7016
- def __post_init__(self) -> None:
7017
- hash(self)
7018
- check.not_empty(self.parts)
7019
- for k, g in itertools.groupby(self.parts, type):
7020
- if len(gl := list(g)) > 1:
7021
- raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
6809
+ _DEPLOY_TAGS_BY_KWARG: ta.Dict[str, ta.Type[DeployTag]] = {}
6810
+ DEPLOY_TAGS_BY_KWARG: ta.Mapping[str, ta.Type[DeployTag]] = _DEPLOY_TAGS_BY_KWARG
7022
6811
 
7023
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7024
- return ''.join(p.render(tags) for p in self.parts)
7025
6812
 
7026
- @classmethod
7027
- def parse(cls, s: str) -> 'DeployPathName':
7028
- check.non_empty_str(s)
7029
- check.not_in('/', s)
6813
+ def _register_deploy_tag(cls):
6814
+ check.not_in(cls.tag_name, _DEPLOY_TAGS_BY_NAME)
6815
+ check.not_in(cls.tag_kwarg, _DEPLOY_TAGS_BY_KWARG)
7030
6816
 
7031
- i = 0
7032
- ps = []
7033
- while i < len(s):
7034
- ns = [(n, d) for d in DEPLOY_TAG_DELIMITERS if (n := s.find(d, i)) >= 0]
7035
- if not ns:
7036
- ps.append(s[i:])
7037
- break
7038
- n, d = min(ns)
7039
- ps.append(check.non_empty_str(s[i:n]))
7040
- ps.append(s[n:n + len(d)])
7041
- i = n + len(d)
6817
+ _DEPLOY_TAGS.add(cls)
6818
+ _DEPLOY_TAGS_BY_NAME[cls.tag_name] = cls
6819
+ _DEPLOY_TAGS_BY_KWARG[cls.tag_kwarg] = cls
7042
6820
 
7043
- return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
6821
+ register_type_obj_marshaler(cls, SingleFieldObjMarshaler(cls, 's'))
6822
+
6823
+ return cls
7044
6824
 
7045
6825
 
7046
6826
  ##
7047
6827
 
7048
6828
 
7049
- @dc.dataclass(frozen=True)
7050
- class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7051
- name: DeployPathName
6829
+ @_register_deploy_tag
6830
+ class DeployTime(DeployTag):
6831
+ tag_name: ta.ClassVar[str] = 'time'
7052
6832
 
7053
- @property
7054
- @abc.abstractmethod
7055
- def kind(self) -> DeployPathKind:
7056
- raise NotImplementedError
7057
6833
 
7058
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7059
- return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
6834
+ ##
7060
6835
 
7061
- @classmethod
7062
- def parse(cls, s: str) -> 'DeployPathPart':
7063
- if (is_dir := s.endswith('/')):
7064
- s = s[:-1]
7065
- check.non_empty_str(s)
7066
- check.not_in('/', s)
7067
6836
 
7068
- n = DeployPathName.parse(s)
7069
- if is_dir:
7070
- return DirDeployPathPart(n)
7071
- else:
7072
- return FileDeployPathPart(n)
6837
+ class NameDeployTag(DeployTag, abc.ABC): # noqa
6838
+ pass
7073
6839
 
7074
6840
 
7075
- class DirDeployPathPart(DeployPathPart):
7076
- @property
7077
- def kind(self) -> DeployPathKind:
7078
- return 'dir'
6841
+ @_register_deploy_tag
6842
+ class DeployApp(NameDeployTag):
6843
+ tag_name: ta.ClassVar[str] = 'app'
7079
6844
 
7080
6845
 
7081
- class FileDeployPathPart(DeployPathPart):
7082
- @property
7083
- def kind(self) -> DeployPathKind:
7084
- return 'file'
6846
+ @_register_deploy_tag
6847
+ class DeployConf(NameDeployTag):
6848
+ tag_name: ta.ClassVar[str] = 'conf'
7085
6849
 
7086
6850
 
7087
6851
  ##
7088
6852
 
7089
6853
 
7090
- @dc.dataclass(frozen=True)
7091
- class DeployPath:
7092
- parts: ta.Sequence[DeployPathPart]
7093
-
7094
- @property
7095
- def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7096
- for p in self.parts:
7097
- yield from p.name.parts
7098
-
7099
- def __post_init__(self) -> None:
7100
- hash(self)
7101
- check.not_empty(self.parts)
7102
- for p in self.parts[:-1]:
7103
- check.equal(p.kind, 'dir')
7104
-
7105
- @cached_nullary
7106
- def tag_indices(self) -> ta.Mapping[ta.Type[DeployTag], ta.Sequence[int]]:
7107
- pd: ta.Dict[ta.Type[DeployTag], ta.List[int]] = {}
7108
- for i, np in enumerate(self.name_parts):
7109
- if isinstance(np, TagDeployPathNamePart):
7110
- pd.setdefault(np.tag, []).append(i)
7111
- return pd
7112
-
7113
- @property
7114
- def kind(self) -> DeployPathKind:
7115
- return self.parts[-1].kind
6854
+ class KeyDeployTag(DeployTag, abc.ABC): # noqa
6855
+ pass
7116
6856
 
7117
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7118
- return ''.join([p.render(tags) for p in self.parts])
7119
6857
 
7120
- @classmethod
7121
- def parse(cls, s: str) -> 'DeployPath':
7122
- check.non_empty_str(s)
7123
- ps = split_keep_delimiter(s, '/')
7124
- return cls(tuple(DeployPathPart.parse(p) for p in ps))
6858
+ @_register_deploy_tag
6859
+ class DeployKey(KeyDeployTag):
6860
+ tag_name: ta.ClassVar[str] = 'deploy-key'
7125
6861
 
7126
6862
 
7127
- ########################################
7128
- # ../deploy/specs.py
6863
+ @_register_deploy_tag
6864
+ class DeployAppKey(KeyDeployTag):
6865
+ tag_name: ta.ClassVar[str] = 'app-key'
7129
6866
 
7130
6867
 
7131
6868
  ##
7132
6869
 
7133
6870
 
7134
- def check_valid_deploy_spec_path(s: str) -> str:
7135
- check.non_empty_str(s)
7136
- for c in ['..', '//']:
7137
- check.not_in(c, s)
7138
- check.arg(not s.startswith('/'))
7139
- return s
7140
-
6871
+ class RevDeployTag(DeployTag, abc.ABC): # noqa
6872
+ pass
7141
6873
 
7142
- class DeploySpecKeyed(ta.Generic[KeyDeployTagT]):
7143
- @cached_nullary
7144
- def _key_str(self) -> str:
7145
- return hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8]
7146
6874
 
7147
- @abc.abstractmethod
7148
- def key(self) -> KeyDeployTagT:
7149
- raise NotImplementedError
6875
+ @_register_deploy_tag
6876
+ class DeployAppRev(RevDeployTag):
6877
+ tag_name: ta.ClassVar[str] = 'app-rev'
7150
6878
 
7151
6879
 
7152
6880
  ##
7153
6881
 
7154
6882
 
7155
- @dc.dataclass(frozen=True)
7156
- class DeployGitRepo:
7157
- host: ta.Optional[str] = None
7158
- username: ta.Optional[str] = None
7159
- path: ta.Optional[str] = None
6883
+ class DeployTagMap:
6884
+ def __init__(
6885
+ self,
6886
+ *args: DeployTag,
6887
+ **kwargs: str,
6888
+ ) -> None:
6889
+ super().__init__()
7160
6890
 
7161
- def __post_init__(self) -> None:
7162
- check.not_in('..', check.non_empty_str(self.host))
7163
- check.not_in('.', check.non_empty_str(self.path))
6891
+ dct: ta.Dict[ta.Type[DeployTag], DeployTag] = {}
7164
6892
 
6893
+ for a in args:
6894
+ c = type(check.isinstance(a, DeployTag))
6895
+ check.not_in(c, dct)
6896
+ dct[c] = a
7165
6897
 
7166
- @dc.dataclass(frozen=True)
7167
- class DeployGitSpec:
7168
- repo: DeployGitRepo
7169
- rev: DeployRev
7170
-
7171
- subtrees: ta.Optional[ta.Sequence[str]] = None
7172
-
7173
- def __post_init__(self) -> None:
7174
- check.non_empty_str(self.rev)
7175
- if self.subtrees is not None:
7176
- for st in self.subtrees:
7177
- check.non_empty_str(st)
7178
-
7179
-
7180
- ##
7181
-
7182
-
7183
- @dc.dataclass(frozen=True)
7184
- class DeployVenvSpec:
7185
- interp: ta.Optional[str] = None
7186
-
7187
- requirements_files: ta.Optional[ta.Sequence[str]] = None
7188
- extra_dependencies: ta.Optional[ta.Sequence[str]] = None
7189
-
7190
- use_uv: bool = False
7191
-
7192
-
7193
- ##
7194
-
7195
-
7196
- @dc.dataclass(frozen=True)
7197
- class DeployAppConfFile:
7198
- path: str
7199
- body: str
7200
-
7201
- def __post_init__(self) -> None:
7202
- check_valid_deploy_spec_path(self.path)
7203
-
7204
-
7205
- #
7206
-
7207
-
7208
- @dc.dataclass(frozen=True)
7209
- class DeployAppConfLink: # noqa
7210
- """
7211
- May be either:
7212
- - @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7213
- - @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7214
- - @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7215
- """
7216
-
7217
- src: str
7218
-
7219
- kind: ta.Literal['current_only', 'all_active'] = 'current_only'
7220
-
7221
- def __post_init__(self) -> None:
7222
- check_valid_deploy_spec_path(self.src)
7223
- if '/' in self.src:
7224
- check.equal(self.src.count('/'), 1)
7225
-
7226
-
7227
- #
7228
-
7229
-
7230
- @dc.dataclass(frozen=True)
7231
- class DeployAppConfSpec:
7232
- files: ta.Optional[ta.Sequence[DeployAppConfFile]] = None
7233
-
7234
- links: ta.Optional[ta.Sequence[DeployAppConfLink]] = None
7235
-
7236
- def __post_init__(self) -> None:
7237
- if self.files:
7238
- seen: ta.Set[str] = set()
7239
- for f in self.files:
7240
- check.not_in(f.path, seen)
7241
- seen.add(f.path)
6898
+ for k, v in kwargs.items():
6899
+ c = DEPLOY_TAGS_BY_KWARG[k]
6900
+ check.not_in(c, dct)
6901
+ dct[c] = c(v)
7242
6902
 
6903
+ self._dct = dct
6904
+ self._tup = tuple(sorted((type(t).tag_kwarg, t.s) for t in dct.values()))
7243
6905
 
7244
- ##
6906
+ #
7245
6907
 
6908
+ def add(self, *args: ta.Any, **kwargs: ta.Any) -> 'DeployTagMap':
6909
+ return DeployTagMap(
6910
+ *self,
6911
+ *args,
6912
+ **kwargs,
6913
+ )
7246
6914
 
7247
- @dc.dataclass(frozen=True)
7248
- class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
7249
- app: DeployApp
6915
+ def remove(self, *tags_or_names: ta.Union[ta.Type[DeployTag], str]) -> 'DeployTagMap':
6916
+ dcs = {
6917
+ check.issubclass(a, DeployTag) if isinstance(a, type) else DEPLOY_TAGS_BY_NAME[a]
6918
+ for a in tags_or_names
6919
+ }
7250
6920
 
7251
- git: DeployGitSpec
6921
+ return DeployTagMap(*[
6922
+ t
6923
+ for t in self._dct.values()
6924
+ if t not in dcs
6925
+ ])
7252
6926
 
7253
- venv: ta.Optional[DeployVenvSpec] = None
6927
+ #
7254
6928
 
7255
- conf: ta.Optional[DeployAppConfSpec] = None
6929
+ def __repr__(self) -> str:
6930
+ return f'{self.__class__.__name__}({", ".join(f"{k}={v!r}" for k, v in self._tup)})'
7256
6931
 
7257
- # @ta.override
7258
- def key(self) -> DeployAppKey:
7259
- return DeployAppKey(self._key_str())
6932
+ def __hash__(self) -> int:
6933
+ return hash(self._tup)
7260
6934
 
6935
+ def __eq__(self, other: object) -> bool:
6936
+ if isinstance(other, DeployTagMap):
6937
+ return self._tup == other._tup
6938
+ else:
6939
+ return NotImplemented
7261
6940
 
7262
- ##
6941
+ #
7263
6942
 
6943
+ def __len__(self) -> int:
6944
+ return len(self._dct)
7264
6945
 
7265
- @dc.dataclass(frozen=True)
7266
- class DeploySpec(DeploySpecKeyed[DeployKey]):
7267
- apps: ta.Sequence[DeployAppSpec]
6946
+ def __iter__(self) -> ta.Iterator[DeployTag]:
6947
+ return iter(self._dct.values())
7268
6948
 
7269
- def __post_init__(self) -> None:
7270
- seen: ta.Set[DeployApp] = set()
7271
- for a in self.apps:
7272
- if a.app in seen:
7273
- raise KeyError(a.app)
7274
- seen.add(a.app)
6949
+ def __getitem__(self, key: ta.Union[ta.Type[DeployTag], str]) -> DeployTag:
6950
+ if isinstance(key, str):
6951
+ return self._dct[DEPLOY_TAGS_BY_NAME[key]]
6952
+ elif isinstance(key, type):
6953
+ return self._dct[key]
6954
+ else:
6955
+ raise TypeError(key)
7275
6956
 
7276
- # @ta.override
7277
- def key(self) -> DeployKey:
7278
- return DeployKey(self._key_str())
6957
+ def __contains__(self, key: ta.Union[ta.Type[DeployTag], str]) -> bool:
6958
+ if isinstance(key, str):
6959
+ return DEPLOY_TAGS_BY_NAME[key] in self._dct
6960
+ elif isinstance(key, type):
6961
+ return key in self._dct
6962
+ else:
6963
+ raise TypeError(key)
7279
6964
 
7280
6965
 
7281
6966
  ########################################
@@ -7863,291 +7548,418 @@ class LocalCommandExecutor(CommandExecutor):
7863
7548
 
7864
7549
 
7865
7550
  ########################################
7866
- # ../deploy/conf.py
7551
+ # ../deploy/paths/paths.py
7867
7552
  """
7868
7553
  TODO:
7869
- - @conf DeployPathPlaceholder? :|
7870
- - post-deploy: remove any dir_links not present in new spec
7871
- - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
7872
- - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
7873
- - ** whole thing can be atomic **
7874
- - 1) new atomic temp dir
7875
- - 2) for each subdir not needing modification, hardlink into temp dir
7876
- - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
7877
- - 4) write (or if deleting, omit) new files
7878
- - 5) swap top level
7879
- - ** whole deploy can be atomic(-ish) - do this for everything **
7880
- - just a '/deploy/current' dir
7881
- - some things (venvs) cannot be moved, thus the /deploy/venvs dir
7882
- - ** ensure (enforce) equivalent relpath nesting
7554
+ - run/{.pid,.sock}
7555
+ - logs/...
7556
+ - current symlink
7557
+ - conf/{nginx,supervisor}
7558
+ - env/?
7559
+ - apps/<app>/shared
7883
7560
  """
7884
7561
 
7885
7562
 
7886
- class DeployConfManager:
7887
- def __init__(
7888
- self,
7889
- *,
7890
- deploy_home: ta.Optional[DeployHome] = None,
7891
- ) -> None:
7892
- super().__init__()
7893
-
7894
- self._deploy_home = deploy_home
7563
+ ##
7895
7564
 
7896
- #
7897
7565
 
7898
- async def _write_app_conf_file(
7899
- self,
7900
- acf: DeployAppConfFile,
7901
- app_conf_dir: str,
7902
- ) -> None:
7903
- conf_file = os.path.join(app_conf_dir, acf.path)
7904
- check.arg(is_path_in_dir(app_conf_dir, conf_file))
7566
+ class DeployPathError(Exception):
7567
+ pass
7905
7568
 
7906
- os.makedirs(os.path.dirname(conf_file), exist_ok=True)
7907
7569
 
7908
- with open(conf_file, 'w') as f: # noqa
7909
- f.write(acf.body)
7570
+ class DeployPathRenderable(abc.ABC):
7571
+ @abc.abstractmethod
7572
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7573
+ raise NotImplementedError
7910
7574
 
7911
- #
7912
7575
 
7913
- class _ComputedConfLink(ta.NamedTuple):
7914
- conf: DeployConf
7915
- is_dir: bool
7916
- link_src: str
7917
- link_dst: str
7576
+ ##
7918
7577
 
7919
- _UNIQUE_LINK_NAME_STR = '@app--@time--@app-key'
7920
- _UNIQUE_LINK_NAME = DeployPath.parse(_UNIQUE_LINK_NAME_STR)
7921
7578
 
7579
+ class DeployPathNamePart(DeployPathRenderable, abc.ABC):
7922
7580
  @classmethod
7923
- def _compute_app_conf_link_dst(
7924
- cls,
7925
- link: DeployAppConfLink,
7926
- tags: DeployTagMap,
7927
- app_conf_dir: str,
7928
- conf_link_dir: str,
7929
- ) -> _ComputedConfLink:
7930
- link_src = os.path.join(app_conf_dir, link.src)
7931
- check.arg(is_path_in_dir(app_conf_dir, link_src))
7932
-
7933
- #
7581
+ def parse(cls, s: str) -> 'DeployPathNamePart':
7582
+ check.non_empty_str(s)
7583
+ if s.startswith(DEPLOY_TAG_SIGIL):
7584
+ return TagDeployPathNamePart(s[1:])
7585
+ elif s in DEPLOY_TAG_DELIMITERS:
7586
+ return DelimiterDeployPathNamePart(s)
7587
+ else:
7588
+ return ConstDeployPathNamePart(s)
7934
7589
 
7935
- if (is_dir := link.src.endswith('/')):
7936
- # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7937
- check.arg(link.src.count('/') == 1)
7938
- conf = DeployConf(link.src.split('/')[0])
7939
- link_dst_pfx = link.src
7940
- link_dst_sfx = ''
7941
7590
 
7942
- elif '/' in link.src:
7943
- # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7944
- d, f = os.path.split(link.src)
7945
- # TODO: check filename :|
7946
- conf = DeployConf(d)
7947
- link_dst_pfx = d + '/'
7948
- link_dst_sfx = DEPLOY_TAG_SEPARATOR + f
7591
+ @dc.dataclass(frozen=True)
7592
+ class TagDeployPathNamePart(DeployPathNamePart):
7593
+ name: str
7949
7594
 
7950
- else: # noqa
7951
- # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7952
- if '.' in link.src:
7953
- l, _, r = link.src.partition('.')
7954
- conf = DeployConf(l)
7955
- link_dst_pfx = l + '/'
7956
- link_dst_sfx = '.' + r
7957
- else:
7958
- conf = DeployConf(link.src)
7959
- link_dst_pfx = link.src + '/'
7960
- link_dst_sfx = ''
7595
+ def __post_init__(self) -> None:
7596
+ check.in_(self.name, DEPLOY_TAGS_BY_NAME)
7961
7597
 
7962
- #
7598
+ @property
7599
+ def tag(self) -> ta.Type[DeployTag]:
7600
+ return DEPLOY_TAGS_BY_NAME[self.name]
7963
7601
 
7964
- if link.kind == 'current_only':
7965
- link_dst_mid = str(tags[DeployApp].s)
7966
- elif link.kind == 'all_active':
7967
- link_dst_mid = cls._UNIQUE_LINK_NAME.render(tags)
7602
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7603
+ if tags is not None:
7604
+ return tags[self.tag].s
7968
7605
  else:
7969
- raise TypeError(link)
7606
+ return DEPLOY_TAG_SIGIL + self.name
7970
7607
 
7971
- #
7972
7608
 
7973
- link_dst_name = ''.join([
7974
- link_dst_pfx,
7975
- link_dst_mid,
7976
- link_dst_sfx,
7977
- ])
7978
- link_dst = os.path.join(conf_link_dir, link_dst_name)
7609
+ @dc.dataclass(frozen=True)
7610
+ class DelimiterDeployPathNamePart(DeployPathNamePart):
7611
+ delimiter: str
7979
7612
 
7980
- return DeployConfManager._ComputedConfLink(
7981
- conf=conf,
7982
- is_dir=is_dir,
7983
- link_src=link_src,
7984
- link_dst=link_dst,
7985
- )
7613
+ def __post_init__(self) -> None:
7614
+ check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
7986
7615
 
7987
- async def _make_app_conf_link(
7988
- self,
7989
- link: DeployAppConfLink,
7990
- tags: DeployTagMap,
7991
- app_conf_dir: str,
7992
- conf_link_dir: str,
7993
- ) -> None:
7994
- comp = self._compute_app_conf_link_dst(
7995
- link,
7996
- tags,
7997
- app_conf_dir,
7998
- conf_link_dir,
7999
- )
7616
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7617
+ return self.delimiter
8000
7618
 
8001
- #
8002
7619
 
8003
- check.arg(is_path_in_dir(app_conf_dir, comp.link_src))
8004
- check.arg(is_path_in_dir(conf_link_dir, comp.link_dst))
7620
+ @dc.dataclass(frozen=True)
7621
+ class ConstDeployPathNamePart(DeployPathNamePart):
7622
+ const: str
8005
7623
 
8006
- if comp.is_dir:
8007
- check.arg(os.path.isdir(comp.link_src))
8008
- else:
8009
- check.arg(os.path.isfile(comp.link_src))
7624
+ def __post_init__(self) -> None:
7625
+ check.non_empty_str(self.const)
7626
+ for c in [*DEPLOY_TAG_DELIMITERS, DEPLOY_TAG_SIGIL, '/']:
7627
+ check.not_in(c, self.const)
8010
7628
 
8011
- #
7629
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7630
+ return self.const
8012
7631
 
8013
- relative_symlink( # noqa
8014
- comp.link_src,
8015
- comp.link_dst,
8016
- target_is_directory=comp.is_dir,
8017
- make_dirs=True,
8018
- )
8019
7632
 
8020
- #
7633
+ @dc.dataclass(frozen=True)
7634
+ class DeployPathName(DeployPathRenderable):
7635
+ parts: ta.Sequence[DeployPathNamePart]
8021
7636
 
8022
- async def write_app_conf(
8023
- self,
8024
- spec: DeployAppConfSpec,
8025
- tags: DeployTagMap,
8026
- app_conf_dir: str,
8027
- conf_link_dir: str,
8028
- ) -> None:
8029
- for acf in spec.files or []:
8030
- await self._write_app_conf_file(
8031
- acf,
8032
- app_conf_dir,
8033
- )
7637
+ def __post_init__(self) -> None:
7638
+ hash(self)
7639
+ check.not_empty(self.parts)
7640
+ for k, g in itertools.groupby(self.parts, type):
7641
+ if len(gl := list(g)) > 1:
7642
+ raise DeployPathError(f'May not have consecutive path name part types: {k} {gl}')
8034
7643
 
8035
- #
7644
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7645
+ return ''.join(p.render(tags) for p in self.parts)
8036
7646
 
8037
- for link in spec.links or []:
8038
- await self._make_app_conf_link(
8039
- link,
8040
- tags,
8041
- app_conf_dir,
8042
- conf_link_dir,
8043
- )
7647
+ @classmethod
7648
+ def parse(cls, s: str) -> 'DeployPathName':
7649
+ check.non_empty_str(s)
7650
+ check.not_in('/', s)
8044
7651
 
7652
+ i = 0
7653
+ ps = []
7654
+ while i < len(s):
7655
+ ns = [(n, d) for d in DEPLOY_TAG_DELIMITERS if (n := s.find(d, i)) >= 0]
7656
+ if not ns:
7657
+ ps.append(s[i:])
7658
+ break
7659
+ n, d = min(ns)
7660
+ ps.append(check.non_empty_str(s[i:n]))
7661
+ ps.append(s[n:n + len(d)])
7662
+ i = n + len(d)
8045
7663
 
8046
- ########################################
8047
- # ../deploy/paths/owners.py
7664
+ return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
8048
7665
 
8049
7666
 
8050
- class DeployPathOwner(abc.ABC):
7667
+ ##
7668
+
7669
+
7670
+ @dc.dataclass(frozen=True)
7671
+ class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7672
+ name: DeployPathName
7673
+
7674
+ @property
8051
7675
  @abc.abstractmethod
8052
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
7676
+ def kind(self) -> DeployPathKind:
8053
7677
  raise NotImplementedError
8054
7678
 
7679
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7680
+ return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
8055
7681
 
8056
- DeployPathOwners = ta.NewType('DeployPathOwners', ta.Sequence[DeployPathOwner])
7682
+ @classmethod
7683
+ def parse(cls, s: str) -> 'DeployPathPart':
7684
+ if (is_dir := s.endswith('/')):
7685
+ s = s[:-1]
7686
+ check.non_empty_str(s)
7687
+ check.not_in('/', s)
8057
7688
 
7689
+ n = DeployPathName.parse(s)
7690
+ if is_dir:
7691
+ return DirDeployPathPart(n)
7692
+ else:
7693
+ return FileDeployPathPart(n)
8058
7694
 
8059
- class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
8060
- def __init__(
8061
- self,
8062
- *args: ta.Any,
8063
- owned_dir: str,
8064
- deploy_home: ta.Optional[DeployHome],
8065
- **kwargs: ta.Any,
8066
- ) -> None:
8067
- super().__init__(*args, **kwargs)
8068
7695
 
8069
- check.not_in('/', owned_dir)
8070
- self._owned_dir: str = check.non_empty_str(owned_dir)
7696
+ class DirDeployPathPart(DeployPathPart):
7697
+ @property
7698
+ def kind(self) -> DeployPathKind:
7699
+ return 'dir'
8071
7700
 
8072
- self._deploy_home = deploy_home
8073
7701
 
8074
- self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
7702
+ class FileDeployPathPart(DeployPathPart):
7703
+ @property
7704
+ def kind(self) -> DeployPathKind:
7705
+ return 'file'
8075
7706
 
8076
- @cached_nullary
8077
- def _dir(self) -> str:
8078
- return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
8079
7707
 
8080
- @cached_nullary
8081
- def _make_dir(self) -> str:
8082
- if not os.path.isdir(d := self._dir()):
8083
- os.makedirs(d, exist_ok=True)
8084
- return d
7708
+ ##
8085
7709
 
8086
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8087
- return self._owned_deploy_paths
8088
7710
 
7711
+ @dc.dataclass(frozen=True)
7712
+ class DeployPath:
7713
+ parts: ta.Sequence[DeployPathPart]
8089
7714
 
8090
- ########################################
8091
- # ../remote/execution.py
8092
- """
8093
- TODO:
8094
- - sequence all messages
8095
- """
7715
+ @property
7716
+ def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7717
+ for p in self.parts:
7718
+ yield from p.name.parts
8096
7719
 
7720
+ def __post_init__(self) -> None:
7721
+ hash(self)
7722
+ check.not_empty(self.parts)
7723
+ for p in self.parts[:-1]:
7724
+ check.equal(p.kind, 'dir')
8097
7725
 
8098
- ##
7726
+ @cached_nullary
7727
+ def tag_indices(self) -> ta.Mapping[ta.Type[DeployTag], ta.Sequence[int]]:
7728
+ pd: ta.Dict[ta.Type[DeployTag], ta.List[int]] = {}
7729
+ for i, np in enumerate(self.name_parts):
7730
+ if isinstance(np, TagDeployPathNamePart):
7731
+ pd.setdefault(np.tag, []).append(i)
7732
+ return pd
8099
7733
 
7734
+ @property
7735
+ def kind(self) -> DeployPathKind:
7736
+ return self.parts[-1].kind
8100
7737
 
8101
- class _RemoteProtocol:
8102
- class Message(abc.ABC): # noqa
8103
- async def send(self, chan: RemoteChannel) -> None:
8104
- await chan.send_obj(self, _RemoteProtocol.Message)
7738
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7739
+ return ''.join([p.render(tags) for p in self.parts])
8105
7740
 
8106
- @classmethod
8107
- async def recv(cls: ta.Type[T], chan: RemoteChannel) -> ta.Optional[T]:
8108
- return await chan.recv_obj(cls)
7741
+ @classmethod
7742
+ def parse(cls, s: str) -> 'DeployPath':
7743
+ check.non_empty_str(s)
7744
+ ps = split_keep_delimiter(s, '/')
7745
+ return cls(tuple(DeployPathPart.parse(p) for p in ps))
8109
7746
 
8110
- #
8111
7747
 
8112
- class Request(Message, abc.ABC): # noqa
8113
- pass
7748
+ ########################################
7749
+ # ../deploy/specs.py
8114
7750
 
8115
- @dc.dataclass(frozen=True)
8116
- class CommandRequest(Request):
8117
- seq: int
8118
- cmd: Command
8119
7751
 
8120
- @dc.dataclass(frozen=True)
8121
- class PingRequest(Request):
8122
- time: float
7752
+ ##
8123
7753
 
8124
- #
8125
7754
 
8126
- class Response(Message, abc.ABC): # noqa
8127
- pass
7755
+ def check_valid_deploy_spec_path(s: str) -> str:
7756
+ check.non_empty_str(s)
7757
+ for c in ['..', '//']:
7758
+ check.not_in(c, s)
7759
+ check.arg(not s.startswith('/'))
7760
+ return s
8128
7761
 
8129
- @dc.dataclass(frozen=True)
8130
- class LogResponse(Response):
8131
- s: str
8132
7762
 
8133
- @dc.dataclass(frozen=True)
8134
- class CommandResponse(Response):
8135
- seq: int
8136
- res: CommandOutputOrExceptionData
7763
+ class DeploySpecKeyed(ta.Generic[KeyDeployTagT]):
7764
+ @cached_nullary
7765
+ def _key_str(self) -> str:
7766
+ return hashlib.sha256(repr(self).encode('utf-8')).hexdigest()[:8]
8137
7767
 
8138
- @dc.dataclass(frozen=True)
8139
- class PingResponse(Response):
8140
- time: float
7768
+ @abc.abstractmethod
7769
+ def key(self) -> KeyDeployTagT:
7770
+ raise NotImplementedError
8141
7771
 
8142
7772
 
8143
7773
  ##
8144
7774
 
8145
7775
 
8146
- class _RemoteLogHandler(logging.Handler):
8147
- def __init__(
8148
- self,
8149
- chan: RemoteChannel,
8150
- loop: ta.Any = None,
7776
+ @dc.dataclass(frozen=True)
7777
+ class DeployGitRepo:
7778
+ host: ta.Optional[str] = None
7779
+ username: ta.Optional[str] = None
7780
+ path: ta.Optional[str] = None
7781
+
7782
+ def __post_init__(self) -> None:
7783
+ check.not_in('..', check.non_empty_str(self.host))
7784
+ check.not_in('.', check.non_empty_str(self.path))
7785
+
7786
+
7787
+ @dc.dataclass(frozen=True)
7788
+ class DeployGitSpec:
7789
+ repo: DeployGitRepo
7790
+ rev: DeployRev
7791
+
7792
+ subtrees: ta.Optional[ta.Sequence[str]] = None
7793
+
7794
+ def __post_init__(self) -> None:
7795
+ check.non_empty_str(self.rev)
7796
+ if self.subtrees is not None:
7797
+ for st in self.subtrees:
7798
+ check.non_empty_str(st)
7799
+
7800
+
7801
+ ##
7802
+
7803
+
7804
+ @dc.dataclass(frozen=True)
7805
+ class DeployVenvSpec:
7806
+ interp: ta.Optional[str] = None
7807
+
7808
+ requirements_files: ta.Optional[ta.Sequence[str]] = None
7809
+ extra_dependencies: ta.Optional[ta.Sequence[str]] = None
7810
+
7811
+ use_uv: bool = False
7812
+
7813
+
7814
+ ##
7815
+
7816
+
7817
+ @dc.dataclass(frozen=True)
7818
+ class DeployAppConfFile:
7819
+ path: str
7820
+ body: str
7821
+
7822
+ def __post_init__(self) -> None:
7823
+ check_valid_deploy_spec_path(self.path)
7824
+
7825
+
7826
+ #
7827
+
7828
+
7829
+ @dc.dataclass(frozen=True)
7830
+ class DeployAppConfLink: # noqa
7831
+ """
7832
+ May be either:
7833
+ - @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7834
+ - @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7835
+ - @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7836
+ """
7837
+
7838
+ src: str
7839
+
7840
+ kind: ta.Literal['current_only', 'all_active'] = 'current_only'
7841
+
7842
+ def __post_init__(self) -> None:
7843
+ check_valid_deploy_spec_path(self.src)
7844
+ if '/' in self.src:
7845
+ check.equal(self.src.count('/'), 1)
7846
+
7847
+
7848
+ #
7849
+
7850
+
7851
+ @dc.dataclass(frozen=True)
7852
+ class DeployAppConfSpec:
7853
+ files: ta.Optional[ta.Sequence[DeployAppConfFile]] = None
7854
+
7855
+ links: ta.Optional[ta.Sequence[DeployAppConfLink]] = None
7856
+
7857
+ def __post_init__(self) -> None:
7858
+ if self.files:
7859
+ seen: ta.Set[str] = set()
7860
+ for f in self.files:
7861
+ check.not_in(f.path, seen)
7862
+ seen.add(f.path)
7863
+
7864
+
7865
+ ##
7866
+
7867
+
7868
+ @dc.dataclass(frozen=True)
7869
+ class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
7870
+ app: DeployApp
7871
+
7872
+ git: DeployGitSpec
7873
+
7874
+ venv: ta.Optional[DeployVenvSpec] = None
7875
+
7876
+ conf: ta.Optional[DeployAppConfSpec] = None
7877
+
7878
+ # @ta.override
7879
+ def key(self) -> DeployAppKey:
7880
+ return DeployAppKey(self._key_str())
7881
+
7882
+
7883
+ ##
7884
+
7885
+
7886
+ @dc.dataclass(frozen=True)
7887
+ class DeploySpec(DeploySpecKeyed[DeployKey]):
7888
+ apps: ta.Sequence[DeployAppSpec]
7889
+
7890
+ def __post_init__(self) -> None:
7891
+ seen: ta.Set[DeployApp] = set()
7892
+ for a in self.apps:
7893
+ if a.app in seen:
7894
+ raise KeyError(a.app)
7895
+ seen.add(a.app)
7896
+
7897
+ # @ta.override
7898
+ def key(self) -> DeployKey:
7899
+ return DeployKey(self._key_str())
7900
+
7901
+
7902
+ ########################################
7903
+ # ../remote/execution.py
7904
+ """
7905
+ TODO:
7906
+ - sequence all messages
7907
+ """
7908
+
7909
+
7910
+ ##
7911
+
7912
+
7913
+ class _RemoteProtocol:
7914
+ class Message(abc.ABC): # noqa
7915
+ async def send(self, chan: RemoteChannel) -> None:
7916
+ await chan.send_obj(self, _RemoteProtocol.Message)
7917
+
7918
+ @classmethod
7919
+ async def recv(cls: ta.Type[T], chan: RemoteChannel) -> ta.Optional[T]:
7920
+ return await chan.recv_obj(cls)
7921
+
7922
+ #
7923
+
7924
+ class Request(Message, abc.ABC): # noqa
7925
+ pass
7926
+
7927
+ @dc.dataclass(frozen=True)
7928
+ class CommandRequest(Request):
7929
+ seq: int
7930
+ cmd: Command
7931
+
7932
+ @dc.dataclass(frozen=True)
7933
+ class PingRequest(Request):
7934
+ time: float
7935
+
7936
+ #
7937
+
7938
+ class Response(Message, abc.ABC): # noqa
7939
+ pass
7940
+
7941
+ @dc.dataclass(frozen=True)
7942
+ class LogResponse(Response):
7943
+ s: str
7944
+
7945
+ @dc.dataclass(frozen=True)
7946
+ class CommandResponse(Response):
7947
+ seq: int
7948
+ res: CommandOutputOrExceptionData
7949
+
7950
+ @dc.dataclass(frozen=True)
7951
+ class PingResponse(Response):
7952
+ time: float
7953
+
7954
+
7955
+ ##
7956
+
7957
+
7958
+ class _RemoteLogHandler(logging.Handler):
7959
+ def __init__(
7960
+ self,
7961
+ chan: RemoteChannel,
7962
+ loop: ta.Any = None,
8151
7963
  ) -> None:
8152
7964
  super().__init__()
8153
7965
 
@@ -8874,262 +8686,254 @@ class SubprocessCommandExecutor(CommandExecutor[SubprocessCommand, SubprocessCom
8874
8686
 
8875
8687
 
8876
8688
  ########################################
8877
- # ../deploy/git.py
8689
+ # ../deploy/conf.py
8878
8690
  """
8879
8691
  TODO:
8880
- - 'repos'?
8881
-
8882
- git/github.com/wrmsr/omlish <- bootstrap repo
8883
- - shallow clone off bootstrap into /apps
8884
-
8885
- github.com/wrmsr/omlish@rev
8692
+ - @conf DeployPathPlaceholder? :|
8693
+ - post-deploy: remove any dir_links not present in new spec
8694
+ - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
8695
+ - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
8696
+ - ** whole thing can be atomic **
8697
+ - 1) new atomic temp dir
8698
+ - 2) for each subdir not needing modification, hardlink into temp dir
8699
+ - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
8700
+ - 4) write (or if deleting, omit) new files
8701
+ - 5) swap top level
8702
+ - ** whole deploy can be atomic(-ish) - do this for everything **
8703
+ - just a '/deploy/current' dir
8704
+ - some things (venvs) cannot be moved, thus the /deploy/venvs dir
8705
+ - ** ensure (enforce) equivalent relpath nesting
8886
8706
  """
8887
8707
 
8888
8708
 
8889
- ##
8890
-
8891
-
8892
- class DeployGitManager(SingleDirDeployPathOwner):
8709
+ class DeployConfManager:
8893
8710
  def __init__(
8894
8711
  self,
8895
8712
  *,
8896
8713
  deploy_home: ta.Optional[DeployHome] = None,
8897
- atomics: AtomicPathSwapping,
8898
8714
  ) -> None:
8899
- super().__init__(
8900
- owned_dir='git',
8901
- deploy_home=deploy_home,
8902
- )
8715
+ super().__init__()
8903
8716
 
8904
- self._atomics = atomics
8717
+ self._deploy_home = deploy_home
8905
8718
 
8906
- self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
8719
+ #
8907
8720
 
8908
- class RepoDir:
8909
- def __init__(
8910
- self,
8911
- git: 'DeployGitManager',
8912
- repo: DeployGitRepo,
8913
- ) -> None:
8914
- super().__init__()
8721
+ async def _write_app_conf_file(
8722
+ self,
8723
+ acf: DeployAppConfFile,
8724
+ app_conf_dir: str,
8725
+ ) -> None:
8726
+ conf_file = os.path.join(app_conf_dir, acf.path)
8727
+ check.arg(is_path_in_dir(app_conf_dir, conf_file))
8915
8728
 
8916
- self._git = git
8917
- self._repo = repo
8918
- self._dir = os.path.join(
8919
- self._git._make_dir(), # noqa
8920
- check.non_empty_str(repo.host),
8921
- check.non_empty_str(repo.path),
8922
- )
8729
+ os.makedirs(os.path.dirname(conf_file), exist_ok=True)
8923
8730
 
8924
- @property
8925
- def repo(self) -> DeployGitRepo:
8926
- return self._repo
8731
+ with open(conf_file, 'w') as f: # noqa
8732
+ f.write(acf.body)
8927
8733
 
8928
- @property
8929
- def url(self) -> str:
8930
- if self._repo.username is not None:
8931
- return f'{self._repo.username}@{self._repo.host}:{self._repo.path}'
8932
- else:
8933
- return f'https://{self._repo.host}/{self._repo.path}'
8734
+ #
8735
+
8736
+ class _ComputedConfLink(ta.NamedTuple):
8737
+ conf: DeployConf
8738
+ is_dir: bool
8739
+ link_src: str
8740
+ link_dst: str
8741
+
8742
+ _UNIQUE_LINK_NAME_STR = '@app--@time--@app-key'
8743
+ _UNIQUE_LINK_NAME = DeployPath.parse(_UNIQUE_LINK_NAME_STR)
8744
+
8745
+ @classmethod
8746
+ def _compute_app_conf_link_dst(
8747
+ cls,
8748
+ link: DeployAppConfLink,
8749
+ tags: DeployTagMap,
8750
+ app_conf_dir: str,
8751
+ conf_link_dir: str,
8752
+ ) -> _ComputedConfLink:
8753
+ link_src = os.path.join(app_conf_dir, link.src)
8754
+ check.arg(is_path_in_dir(app_conf_dir, link_src))
8934
8755
 
8935
8756
  #
8936
8757
 
8937
- async def _call(self, *cmd: str) -> None:
8938
- await asyncio_subprocesses.check_call(
8939
- *cmd,
8940
- cwd=self._dir,
8941
- )
8758
+ if (is_dir := link.src.endswith('/')):
8759
+ # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
8760
+ check.arg(link.src.count('/') == 1)
8761
+ conf = DeployConf(link.src.split('/')[0])
8762
+ link_dst_pfx = link.src
8763
+ link_dst_sfx = ''
8764
+
8765
+ elif '/' in link.src:
8766
+ # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
8767
+ d, f = os.path.split(link.src)
8768
+ # TODO: check filename :|
8769
+ conf = DeployConf(d)
8770
+ link_dst_pfx = d + '/'
8771
+ link_dst_sfx = DEPLOY_TAG_SEPARATOR + f
8772
+
8773
+ else: # noqa
8774
+ # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
8775
+ if '.' in link.src:
8776
+ l, _, r = link.src.partition('.')
8777
+ conf = DeployConf(l)
8778
+ link_dst_pfx = l + '/'
8779
+ link_dst_sfx = '.' + r
8780
+ else:
8781
+ conf = DeployConf(link.src)
8782
+ link_dst_pfx = link.src + '/'
8783
+ link_dst_sfx = ''
8942
8784
 
8943
8785
  #
8944
8786
 
8945
- @async_cached_nullary
8946
- async def init(self) -> None:
8947
- os.makedirs(self._dir, exist_ok=True)
8948
- if os.path.exists(os.path.join(self._dir, '.git')):
8949
- return
8787
+ if link.kind == 'current_only':
8788
+ link_dst_mid = str(tags[DeployApp].s)
8789
+ elif link.kind == 'all_active':
8790
+ link_dst_mid = cls._UNIQUE_LINK_NAME.render(tags)
8791
+ else:
8792
+ raise TypeError(link)
8950
8793
 
8951
- await self._call('git', 'init')
8952
- await self._call('git', 'remote', 'add', 'origin', self.url)
8794
+ #
8953
8795
 
8954
- async def fetch(self, rev: DeployRev) -> None:
8955
- await self.init()
8956
- await self._call('git', 'fetch', '--depth=1', 'origin', rev)
8796
+ link_dst_name = ''.join([
8797
+ link_dst_pfx,
8798
+ link_dst_mid,
8799
+ link_dst_sfx,
8800
+ ])
8801
+ link_dst = os.path.join(conf_link_dir, link_dst_name)
8802
+
8803
+ return DeployConfManager._ComputedConfLink(
8804
+ conf=conf,
8805
+ is_dir=is_dir,
8806
+ link_src=link_src,
8807
+ link_dst=link_dst,
8808
+ )
8809
+
8810
+ async def _make_app_conf_link(
8811
+ self,
8812
+ link: DeployAppConfLink,
8813
+ tags: DeployTagMap,
8814
+ app_conf_dir: str,
8815
+ conf_link_dir: str,
8816
+ ) -> None:
8817
+ comp = self._compute_app_conf_link_dst(
8818
+ link,
8819
+ tags,
8820
+ app_conf_dir,
8821
+ conf_link_dir,
8822
+ )
8957
8823
 
8958
8824
  #
8959
8825
 
8960
- async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
8961
- check.state(not os.path.exists(dst_dir))
8962
- with self._git._atomics.begin_atomic_path_swap( # noqa
8963
- 'dir',
8964
- dst_dir,
8965
- auto_commit=True,
8966
- make_dirs=True,
8967
- ) as dst_swap:
8968
- await self.fetch(spec.rev)
8826
+ check.arg(is_path_in_dir(app_conf_dir, comp.link_src))
8827
+ check.arg(is_path_in_dir(conf_link_dir, comp.link_dst))
8969
8828
 
8970
- dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
8971
- await dst_call('git', 'init')
8829
+ if comp.is_dir:
8830
+ check.arg(os.path.isdir(comp.link_src))
8831
+ else:
8832
+ check.arg(os.path.isfile(comp.link_src))
8972
8833
 
8973
- await dst_call('git', 'remote', 'add', 'local', self._dir)
8974
- await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
8975
- await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
8834
+ #
8976
8835
 
8977
- def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
8978
- try:
8979
- return self._repo_dirs[repo]
8980
- except KeyError:
8981
- repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
8982
- return repo_dir
8836
+ relative_symlink( # noqa
8837
+ comp.link_src,
8838
+ comp.link_dst,
8839
+ target_is_directory=comp.is_dir,
8840
+ make_dirs=True,
8841
+ )
8983
8842
 
8984
- async def checkout(
8843
+ #
8844
+
8845
+ async def write_app_conf(
8985
8846
  self,
8986
- spec: DeployGitSpec,
8987
- dst_dir: str,
8847
+ spec: DeployAppConfSpec,
8848
+ tags: DeployTagMap,
8849
+ app_conf_dir: str,
8850
+ conf_link_dir: str,
8988
8851
  ) -> None:
8989
- await self.get_repo_dir(spec.repo).checkout(spec, dst_dir)
8852
+ for acf in spec.files or []:
8853
+ await self._write_app_conf_file(
8854
+ acf,
8855
+ app_conf_dir,
8856
+ )
8857
+
8858
+ #
8859
+
8860
+ for link in spec.links or []:
8861
+ await self._make_app_conf_link(
8862
+ link,
8863
+ tags,
8864
+ app_conf_dir,
8865
+ conf_link_dir,
8866
+ )
8990
8867
 
8991
8868
 
8992
8869
  ########################################
8993
- # ../deploy/paths/manager.py
8870
+ # ../deploy/paths/owners.py
8994
8871
 
8995
8872
 
8996
- class DeployPathsManager:
8873
+ class DeployPathOwner(abc.ABC):
8874
+ @abc.abstractmethod
8875
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8876
+ raise NotImplementedError
8877
+
8878
+
8879
+ DeployPathOwners = ta.NewType('DeployPathOwners', ta.Sequence[DeployPathOwner])
8880
+
8881
+
8882
+ class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
8997
8883
  def __init__(
8998
8884
  self,
8999
- *,
8885
+ *args: ta.Any,
8886
+ owned_dir: str,
9000
8887
  deploy_home: ta.Optional[DeployHome],
9001
- deploy_path_owners: DeployPathOwners,
8888
+ **kwargs: ta.Any,
9002
8889
  ) -> None:
9003
- super().__init__()
8890
+ super().__init__(*args, **kwargs)
8891
+
8892
+ check.not_in('/', owned_dir)
8893
+ self._owned_dir: str = check.non_empty_str(owned_dir)
9004
8894
 
9005
8895
  self._deploy_home = deploy_home
9006
- self._deploy_path_owners = deploy_path_owners
8896
+
8897
+ self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
9007
8898
 
9008
8899
  @cached_nullary
9009
- def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
9010
- dct: ta.Dict[DeployPath, DeployPathOwner] = {}
9011
- for o in self._deploy_path_owners:
9012
- for p in o.get_owned_deploy_paths():
9013
- if p in dct:
9014
- raise DeployPathError(f'Duplicate deploy path owner: {p}')
9015
- dct[p] = o
9016
- return dct
8900
+ def _dir(self) -> str:
8901
+ return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
9017
8902
 
9018
- def validate_deploy_paths(self) -> None:
9019
- self.owners_by_path()
8903
+ @cached_nullary
8904
+ def _make_dir(self) -> str:
8905
+ if not os.path.isdir(d := self._dir()):
8906
+ os.makedirs(d, exist_ok=True)
8907
+ return d
8908
+
8909
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8910
+ return self._owned_deploy_paths
9020
8911
 
9021
8912
 
9022
8913
  ########################################
9023
- # ../deploy/tmp.py
8914
+ # ../remote/_main.py
9024
8915
 
9025
8916
 
9026
- class DeployTmpManager(
9027
- SingleDirDeployPathOwner,
9028
- AtomicPathSwapping,
9029
- ):
9030
- def __init__(
9031
- self,
9032
- *,
9033
- deploy_home: ta.Optional[DeployHome] = None,
9034
- ) -> None:
9035
- super().__init__(
9036
- owned_dir='tmp',
9037
- deploy_home=deploy_home,
9038
- )
8917
+ ##
9039
8918
 
9040
- @cached_nullary
9041
- def _swapping(self) -> AtomicPathSwapping:
9042
- return TempDirAtomicPathSwapping(
9043
- temp_dir=self._make_dir(),
9044
- root_dir=check.non_empty_str(self._deploy_home),
9045
- )
9046
8919
 
9047
- def begin_atomic_path_swap(
9048
- self,
9049
- kind: AtomicPathSwapKind,
9050
- dst_path: str,
9051
- **kwargs: ta.Any,
9052
- ) -> AtomicPathSwap:
9053
- return self._swapping().begin_atomic_path_swap(
9054
- kind,
9055
- dst_path,
9056
- **kwargs,
9057
- )
8920
+ class _RemoteExecutionLogHandler(logging.Handler):
8921
+ def __init__(self, fn: ta.Callable[[str], None]) -> None:
8922
+ super().__init__()
8923
+ self._fn = fn
8924
+
8925
+ def emit(self, record):
8926
+ msg = self.format(record)
8927
+ self._fn(msg)
9058
8928
 
9059
8929
 
9060
- ########################################
9061
- # ../deploy/venvs.py
9062
- """
9063
- TODO:
9064
- - interp
9065
- - share more code with pyproject?
9066
- """
8930
+ ##
9067
8931
 
9068
8932
 
9069
- class DeployVenvManager:
8933
+ class _RemoteExecutionMain:
9070
8934
  def __init__(
9071
8935
  self,
9072
- *,
9073
- atomics: AtomicPathSwapping,
9074
- ) -> None:
9075
- super().__init__()
9076
-
9077
- self._atomics = atomics
9078
-
9079
- async def setup_venv(
9080
- self,
9081
- spec: DeployVenvSpec,
9082
- git_dir: str,
9083
- venv_dir: str,
9084
- ) -> None:
9085
- sys_exe = 'python3'
9086
-
9087
- # !! NOTE: (most) venvs cannot be relocated, so an atomic swap can't be used. it's up to the path manager to
9088
- # garbage collect orphaned dirs.
9089
- await asyncio_subprocesses.check_call(sys_exe, '-m', 'venv', venv_dir)
9090
-
9091
- #
9092
-
9093
- venv_exe = os.path.join(venv_dir, 'bin', 'python3')
9094
-
9095
- #
9096
-
9097
- reqs_txt = os.path.join(git_dir, 'requirements.txt')
9098
-
9099
- if os.path.isfile(reqs_txt):
9100
- if spec.use_uv:
9101
- await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
9102
- pip_cmd = ['-m', 'uv', 'pip']
9103
- else:
9104
- pip_cmd = ['-m', 'pip']
9105
-
9106
- await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
9107
-
9108
-
9109
- ########################################
9110
- # ../remote/_main.py
9111
-
9112
-
9113
- ##
9114
-
9115
-
9116
- class _RemoteExecutionLogHandler(logging.Handler):
9117
- def __init__(self, fn: ta.Callable[[str], None]) -> None:
9118
- super().__init__()
9119
- self._fn = fn
9120
-
9121
- def emit(self, record):
9122
- msg = self.format(record)
9123
- self._fn(msg)
9124
-
9125
-
9126
- ##
9127
-
9128
-
9129
- class _RemoteExecutionMain:
9130
- def __init__(
9131
- self,
9132
- chan: RemoteChannel,
8936
+ chan: RemoteChannel,
9133
8937
  ) -> None:
9134
8938
  super().__init__()
9135
8939
 
@@ -9639,182 +9443,187 @@ def bind_commands(
9639
9443
 
9640
9444
 
9641
9445
  ########################################
9642
- # ../deploy/apps.py
9446
+ # ../deploy/git.py
9447
+ """
9448
+ TODO:
9449
+ - 'repos'?
9450
+
9451
+ git/github.com/wrmsr/omlish <- bootstrap repo
9452
+ - shallow clone off bootstrap into /apps
9453
+
9454
+ github.com/wrmsr/omlish@rev
9455
+ """
9643
9456
 
9644
9457
 
9645
- class DeployAppManager(DeployPathOwner):
9458
+ ##
9459
+
9460
+
9461
+ class DeployGitManager(SingleDirDeployPathOwner):
9646
9462
  def __init__(
9647
9463
  self,
9648
9464
  *,
9649
9465
  deploy_home: ta.Optional[DeployHome] = None,
9650
-
9651
- conf: DeployConfManager,
9652
- git: DeployGitManager,
9653
- venvs: DeployVenvManager,
9466
+ atomics: AtomicPathSwapping,
9654
9467
  ) -> None:
9655
- super().__init__()
9656
-
9657
- self._deploy_home = deploy_home
9658
-
9659
- self._conf = conf
9660
- self._git = git
9661
- self._venvs = venvs
9468
+ super().__init__(
9469
+ owned_dir='git',
9470
+ deploy_home=deploy_home,
9471
+ )
9662
9472
 
9663
- #
9473
+ self._atomics = atomics
9664
9474
 
9665
- _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
9666
- _APP_DIR = DeployPath.parse(_APP_DIR_STR)
9475
+ self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
9667
9476
 
9668
- _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
9669
- _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
9477
+ class RepoDir:
9478
+ def __init__(
9479
+ self,
9480
+ git: 'DeployGitManager',
9481
+ repo: DeployGitRepo,
9482
+ ) -> None:
9483
+ super().__init__()
9670
9484
 
9671
- _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
9672
- _CONF_DEPLOY_DIR = DeployPath.parse(f'{_DEPLOY_DIR_STR}conf/@conf/')
9485
+ self._git = git
9486
+ self._repo = repo
9487
+ self._dir = os.path.join(
9488
+ self._git._make_dir(), # noqa
9489
+ check.non_empty_str(repo.host),
9490
+ check.non_empty_str(repo.path),
9491
+ )
9673
9492
 
9674
- @cached_nullary
9675
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
9676
- return {
9677
- self._APP_DIR,
9493
+ @property
9494
+ def repo(self) -> DeployGitRepo:
9495
+ return self._repo
9678
9496
 
9679
- self._DEPLOY_DIR,
9497
+ @property
9498
+ def url(self) -> str:
9499
+ if self._repo.username is not None:
9500
+ return f'{self._repo.username}@{self._repo.host}:{self._repo.path}'
9501
+ else:
9502
+ return f'https://{self._repo.host}/{self._repo.path}'
9680
9503
 
9681
- self._APP_DEPLOY_LINK,
9682
- self._CONF_DEPLOY_DIR,
9504
+ #
9683
9505
 
9684
- *[
9685
- DeployPath.parse(f'{self._APP_DIR_STR}{sfx}/')
9686
- for sfx in [
9687
- 'conf',
9688
- 'git',
9689
- 'venv',
9690
- ]
9691
- ],
9692
- }
9506
+ async def _call(self, *cmd: str) -> None:
9507
+ await asyncio_subprocesses.check_call(
9508
+ *cmd,
9509
+ cwd=self._dir,
9510
+ )
9693
9511
 
9694
- #
9512
+ #
9695
9513
 
9696
- async def prepare_app(
9697
- self,
9698
- spec: DeployAppSpec,
9699
- tags: DeployTagMap,
9700
- ) -> None:
9701
- deploy_home = check.non_empty_str(self._deploy_home)
9514
+ @async_cached_nullary
9515
+ async def init(self) -> None:
9516
+ os.makedirs(self._dir, exist_ok=True)
9517
+ if os.path.exists(os.path.join(self._dir, '.git')):
9518
+ return
9702
9519
 
9703
- def build_path(pth: DeployPath) -> str:
9704
- return os.path.join(deploy_home, pth.render(tags))
9520
+ await self._call('git', 'init')
9521
+ await self._call('git', 'remote', 'add', 'origin', self.url)
9705
9522
 
9706
- app_dir = build_path(self._APP_DIR)
9707
- deploy_dir = build_path(self._DEPLOY_DIR)
9708
- app_deploy_link = build_path(self._APP_DEPLOY_LINK)
9523
+ async def fetch(self, rev: DeployRev) -> None:
9524
+ await self.init()
9525
+ await self._call('git', 'fetch', '--depth=1', 'origin', rev)
9709
9526
 
9710
9527
  #
9711
9528
 
9712
- os.makedirs(deploy_dir, exist_ok=True)
9713
-
9714
- deploying_link = os.path.join(deploy_home, 'deploys/deploying')
9715
- relative_symlink(
9716
- deploy_dir,
9717
- deploying_link,
9718
- target_is_directory=True,
9719
- make_dirs=True,
9720
- )
9529
+ async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
9530
+ check.state(not os.path.exists(dst_dir))
9531
+ with self._git._atomics.begin_atomic_path_swap( # noqa
9532
+ 'dir',
9533
+ dst_dir,
9534
+ auto_commit=True,
9535
+ make_dirs=True,
9536
+ ) as dst_swap:
9537
+ await self.fetch(spec.rev)
9721
9538
 
9722
- #
9539
+ dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
9540
+ await dst_call('git', 'init')
9723
9541
 
9724
- os.makedirs(app_dir)
9725
- relative_symlink(
9726
- app_dir,
9727
- app_deploy_link,
9728
- target_is_directory=True,
9729
- make_dirs=True,
9730
- )
9542
+ await dst_call('git', 'remote', 'add', 'local', self._dir)
9543
+ await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
9544
+ await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
9731
9545
 
9732
- #
9546
+ def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
9547
+ try:
9548
+ return self._repo_dirs[repo]
9549
+ except KeyError:
9550
+ repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
9551
+ return repo_dir
9733
9552
 
9734
- deploy_conf_dir = os.path.join(deploy_dir, 'conf')
9735
- os.makedirs(deploy_conf_dir, exist_ok=True)
9553
+ async def checkout(
9554
+ self,
9555
+ spec: DeployGitSpec,
9556
+ dst_dir: str,
9557
+ ) -> None:
9558
+ await self.get_repo_dir(spec.repo).checkout(spec, dst_dir)
9736
9559
 
9737
- #
9738
9560
 
9739
- # def mirror_symlinks(src: str, dst: str) -> None:
9740
- # def mirror_link(lp: str) -> None:
9741
- # check.state(os.path.islink(lp))
9742
- # shutil.copy2(
9743
- # lp,
9744
- # os.path.join(dst, os.path.relpath(lp, src)),
9745
- # follow_symlinks=False,
9746
- # )
9747
- #
9748
- # for dp, dns, fns in os.walk(src, followlinks=False):
9749
- # for fn in fns:
9750
- # mirror_link(os.path.join(dp, fn))
9751
- #
9752
- # for dn in dns:
9753
- # dp2 = os.path.join(dp, dn)
9754
- # if os.path.islink(dp2):
9755
- # mirror_link(dp2)
9756
- # else:
9757
- # os.makedirs(os.path.join(dst, os.path.relpath(dp2, src)))
9561
+ ########################################
9562
+ # ../deploy/paths/manager.py
9758
9563
 
9759
- current_link = os.path.join(deploy_home, 'deploys/current')
9760
9564
 
9761
- # if os.path.exists(current_link):
9762
- # mirror_symlinks(
9763
- # os.path.join(current_link, 'conf'),
9764
- # conf_tag_dir,
9765
- # )
9766
- # mirror_symlinks(
9767
- # os.path.join(current_link, 'apps'),
9768
- # os.path.join(deploy_dir, 'apps'),
9769
- # )
9565
+ class DeployPathsManager:
9566
+ def __init__(
9567
+ self,
9568
+ *,
9569
+ deploy_home: ta.Optional[DeployHome],
9570
+ deploy_path_owners: DeployPathOwners,
9571
+ ) -> None:
9572
+ super().__init__()
9770
9573
 
9771
- #
9574
+ self._deploy_home = deploy_home
9575
+ self._deploy_path_owners = deploy_path_owners
9772
9576
 
9773
- app_git_dir = os.path.join(app_dir, 'git')
9774
- await self._git.checkout(
9775
- spec.git,
9776
- app_git_dir,
9777
- )
9778
-
9779
- #
9780
-
9781
- if spec.venv is not None:
9782
- app_venv_dir = os.path.join(app_dir, 'venv')
9783
- await self._venvs.setup_venv(
9784
- spec.venv,
9785
- app_git_dir,
9786
- app_venv_dir,
9787
- )
9788
-
9789
- #
9790
-
9791
- if spec.conf is not None:
9792
- app_conf_dir = os.path.join(app_dir, 'conf')
9793
- await self._conf.write_app_conf(
9794
- spec.conf,
9795
- tags,
9796
- app_conf_dir,
9797
- deploy_conf_dir,
9798
- )
9799
-
9800
- #
9577
+ @cached_nullary
9578
+ def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
9579
+ dct: ta.Dict[DeployPath, DeployPathOwner] = {}
9580
+ for o in self._deploy_path_owners:
9581
+ for p in o.get_owned_deploy_paths():
9582
+ if p in dct:
9583
+ raise DeployPathError(f'Duplicate deploy path owner: {p}')
9584
+ dct[p] = o
9585
+ return dct
9801
9586
 
9802
- os.replace(deploying_link, current_link)
9587
+ def validate_deploy_paths(self) -> None:
9588
+ self.owners_by_path()
9803
9589
 
9804
9590
 
9805
9591
  ########################################
9806
- # ../deploy/paths/inject.py
9592
+ # ../deploy/tmp.py
9807
9593
 
9808
9594
 
9809
- def bind_deploy_paths() -> InjectorBindings:
9810
- lst: ta.List[InjectorBindingOrBindings] = [
9811
- inj.bind_array(DeployPathOwner),
9812
- inj.bind_array_type(DeployPathOwner, DeployPathOwners),
9595
+ class DeployTmpManager(
9596
+ SingleDirDeployPathOwner,
9597
+ AtomicPathSwapping,
9598
+ ):
9599
+ def __init__(
9600
+ self,
9601
+ *,
9602
+ deploy_home: ta.Optional[DeployHome] = None,
9603
+ ) -> None:
9604
+ super().__init__(
9605
+ owned_dir='tmp',
9606
+ deploy_home=deploy_home,
9607
+ )
9813
9608
 
9814
- inj.bind(DeployPathsManager, singleton=True),
9815
- ]
9609
+ @cached_nullary
9610
+ def _swapping(self) -> AtomicPathSwapping:
9611
+ return TempDirAtomicPathSwapping(
9612
+ temp_dir=self._make_dir(),
9613
+ root_dir=check.non_empty_str(self._deploy_home),
9614
+ )
9816
9615
 
9817
- return inj.as_bindings(*lst)
9616
+ def begin_atomic_path_swap(
9617
+ self,
9618
+ kind: AtomicPathSwapKind,
9619
+ dst_path: str,
9620
+ **kwargs: ta.Any,
9621
+ ) -> AtomicPathSwap:
9622
+ return self._swapping().begin_atomic_path_swap(
9623
+ kind,
9624
+ dst_path,
9625
+ **kwargs,
9626
+ )
9818
9627
 
9819
9628
 
9820
9629
  ########################################
@@ -10532,90 +10341,358 @@ class SystemInterpProvider(InterpProvider):
10532
10341
 
10533
10342
 
10534
10343
  ########################################
10535
- # ../deploy/deploy.py
10344
+ # ../deploy/paths/inject.py
10536
10345
 
10537
10346
 
10538
- DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
10347
+ def bind_deploy_paths() -> InjectorBindings:
10348
+ lst: ta.List[InjectorBindingOrBindings] = [
10349
+ inj.bind_array(DeployPathOwner),
10350
+ inj.bind_array_type(DeployPathOwner, DeployPathOwners),
10539
10351
 
10352
+ inj.bind(DeployPathsManager, singleton=True),
10353
+ ]
10540
10354
 
10541
- DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10355
+ return inj.as_bindings(*lst)
10542
10356
 
10543
10357
 
10544
- class DeployManager:
10545
- def __init__(
10546
- self,
10547
- *,
10548
- apps: DeployAppManager,
10549
- paths: DeployPathsManager,
10358
+ ########################################
10359
+ # ../remote/inject.py
10550
10360
 
10551
- utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10552
- ):
10553
- super().__init__()
10554
10361
 
10555
- self._apps = apps
10556
- self._paths = paths
10362
+ def bind_remote(
10363
+ *,
10364
+ remote_config: RemoteConfig,
10365
+ ) -> InjectorBindings:
10366
+ lst: ta.List[InjectorBindingOrBindings] = [
10367
+ inj.bind(remote_config),
10557
10368
 
10558
- self._utc_clock = utc_clock
10369
+ inj.bind(SubprocessRemoteSpawning, singleton=True),
10370
+ inj.bind(RemoteSpawning, to_key=SubprocessRemoteSpawning),
10559
10371
 
10560
- def _utc_now(self) -> datetime.datetime:
10561
- if self._utc_clock is not None:
10562
- return self._utc_clock() # noqa
10372
+ inj.bind(PyremoteRemoteExecutionConnector, singleton=True),
10373
+ inj.bind(InProcessRemoteExecutionConnector, singleton=True),
10374
+ ]
10375
+
10376
+ #
10377
+
10378
+ if (pf := remote_config.payload_file) is not None:
10379
+ lst.append(inj.bind(pf, key=RemoteExecutionPayloadFile))
10380
+
10381
+ #
10382
+
10383
+ return inj.as_bindings(*lst)
10384
+
10385
+
10386
+ ########################################
10387
+ # ../system/inject.py
10388
+
10389
+
10390
+ def bind_system(
10391
+ *,
10392
+ system_config: SystemConfig,
10393
+ ) -> InjectorBindings:
10394
+ lst: ta.List[InjectorBindingOrBindings] = [
10395
+ inj.bind(system_config),
10396
+ ]
10397
+
10398
+ #
10399
+
10400
+ platform = system_config.platform or detect_system_platform()
10401
+ lst.append(inj.bind(platform, key=Platform))
10402
+
10403
+ #
10404
+
10405
+ if isinstance(platform, AmazonLinuxPlatform):
10406
+ lst.extend([
10407
+ inj.bind(YumSystemPackageManager, singleton=True),
10408
+ inj.bind(SystemPackageManager, to_key=YumSystemPackageManager),
10409
+ ])
10410
+
10411
+ elif isinstance(platform, LinuxPlatform):
10412
+ lst.extend([
10413
+ inj.bind(AptSystemPackageManager, singleton=True),
10414
+ inj.bind(SystemPackageManager, to_key=AptSystemPackageManager),
10415
+ ])
10416
+
10417
+ elif isinstance(platform, DarwinPlatform):
10418
+ lst.extend([
10419
+ inj.bind(BrewSystemPackageManager, singleton=True),
10420
+ inj.bind(SystemPackageManager, to_key=BrewSystemPackageManager),
10421
+ ])
10422
+
10423
+ #
10424
+
10425
+ lst.extend([
10426
+ bind_command(CheckSystemPackageCommand, CheckSystemPackageCommandExecutor),
10427
+ ])
10428
+
10429
+ #
10430
+
10431
+ return inj.as_bindings(*lst)
10432
+
10433
+
10434
+ ########################################
10435
+ # ../targets/connection.py
10436
+
10437
+
10438
+ ##
10439
+
10440
+
10441
+ class ManageTargetConnector(abc.ABC):
10442
+ @abc.abstractmethod
10443
+ def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10444
+ raise NotImplementedError
10445
+
10446
+ def _default_python(self, python: ta.Optional[ta.Sequence[str]]) -> ta.Sequence[str]:
10447
+ check.not_isinstance(python, str)
10448
+ if python is not None:
10449
+ return python
10563
10450
  else:
10564
- return datetime.datetime.now(tz=datetime.timezone.utc) # noqa
10451
+ return ['sh', '-c', get_best_python_sh(), '--']
10565
10452
 
10566
- def _make_deploy_time(self) -> DeployTime:
10567
- return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10568
10453
 
10569
- async def run_deploy(
10454
+ ##
10455
+
10456
+
10457
+ ManageTargetConnectorMap = ta.NewType('ManageTargetConnectorMap', ta.Mapping[ta.Type[ManageTarget], ManageTargetConnector]) # noqa
10458
+
10459
+
10460
+ @dc.dataclass(frozen=True)
10461
+ class TypeSwitchedManageTargetConnector(ManageTargetConnector):
10462
+ connectors: ManageTargetConnectorMap
10463
+
10464
+ def get_connector(self, ty: ta.Type[ManageTarget]) -> ManageTargetConnector:
10465
+ for k, v in self.connectors.items():
10466
+ if issubclass(ty, k):
10467
+ return v
10468
+ raise KeyError(ty)
10469
+
10470
+ def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10471
+ return self.get_connector(type(tgt)).connect(tgt)
10472
+
10473
+
10474
+ ##
10475
+
10476
+
10477
+ @dc.dataclass(frozen=True)
10478
+ class LocalManageTargetConnector(ManageTargetConnector):
10479
+ _local_executor: LocalCommandExecutor
10480
+ _in_process_connector: InProcessRemoteExecutionConnector
10481
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10482
+ _bootstrap: MainBootstrap
10483
+
10484
+ @contextlib.asynccontextmanager
10485
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10486
+ lmt = check.isinstance(tgt, LocalManageTarget)
10487
+
10488
+ if isinstance(lmt, InProcessManageTarget):
10489
+ imt = check.isinstance(lmt, InProcessManageTarget)
10490
+
10491
+ if imt.mode == InProcessManageTarget.Mode.DIRECT:
10492
+ yield self._local_executor
10493
+
10494
+ elif imt.mode == InProcessManageTarget.Mode.FAKE_REMOTE:
10495
+ async with self._in_process_connector.connect() as rce:
10496
+ yield rce
10497
+
10498
+ else:
10499
+ raise TypeError(imt.mode)
10500
+
10501
+ elif isinstance(lmt, SubprocessManageTarget):
10502
+ async with self._pyremote_connector.connect(
10503
+ RemoteSpawning.Target(
10504
+ python=self._default_python(lmt.python),
10505
+ ),
10506
+ self._bootstrap,
10507
+ ) as rce:
10508
+ yield rce
10509
+
10510
+ else:
10511
+ raise TypeError(lmt)
10512
+
10513
+
10514
+ ##
10515
+
10516
+
10517
+ @dc.dataclass(frozen=True)
10518
+ class DockerManageTargetConnector(ManageTargetConnector):
10519
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10520
+ _bootstrap: MainBootstrap
10521
+
10522
+ @contextlib.asynccontextmanager
10523
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10524
+ dmt = check.isinstance(tgt, DockerManageTarget)
10525
+
10526
+ sh_parts: ta.List[str] = ['docker']
10527
+ if dmt.image is not None:
10528
+ sh_parts.extend(['run', '-i', dmt.image])
10529
+ elif dmt.container_id is not None:
10530
+ sh_parts.extend(['exec', '-i', dmt.container_id])
10531
+ else:
10532
+ raise ValueError(dmt)
10533
+
10534
+ async with self._pyremote_connector.connect(
10535
+ RemoteSpawning.Target(
10536
+ shell=' '.join(sh_parts),
10537
+ python=self._default_python(dmt.python),
10538
+ ),
10539
+ self._bootstrap,
10540
+ ) as rce:
10541
+ yield rce
10542
+
10543
+
10544
+ ##
10545
+
10546
+
10547
+ @dc.dataclass(frozen=True)
10548
+ class SshManageTargetConnector(ManageTargetConnector):
10549
+ _pyremote_connector: PyremoteRemoteExecutionConnector
10550
+ _bootstrap: MainBootstrap
10551
+
10552
+ @contextlib.asynccontextmanager
10553
+ async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10554
+ smt = check.isinstance(tgt, SshManageTarget)
10555
+
10556
+ sh_parts: ta.List[str] = ['ssh']
10557
+ if smt.key_file is not None:
10558
+ sh_parts.extend(['-i', smt.key_file])
10559
+ addr = check.not_none(smt.host)
10560
+ if smt.username is not None:
10561
+ addr = f'{smt.username}@{addr}'
10562
+ sh_parts.append(addr)
10563
+
10564
+ async with self._pyremote_connector.connect(
10565
+ RemoteSpawning.Target(
10566
+ shell=' '.join(sh_parts),
10567
+ shell_quote=True,
10568
+ python=self._default_python(smt.python),
10569
+ ),
10570
+ self._bootstrap,
10571
+ ) as rce:
10572
+ yield rce
10573
+
10574
+
10575
+ ########################################
10576
+ # ../../../omdev/interp/resolvers.py
10577
+
10578
+
10579
+ INTERP_PROVIDER_TYPES_BY_NAME: ta.Mapping[str, ta.Type[InterpProvider]] = {
10580
+ cls.name: cls for cls in deep_subclasses(InterpProvider) if abc.ABC not in cls.__bases__ # type: ignore
10581
+ }
10582
+
10583
+
10584
+ class InterpResolver:
10585
+ def __init__(
10570
10586
  self,
10571
- spec: DeploySpec,
10587
+ providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10572
10588
  ) -> None:
10573
- self._paths.validate_deploy_paths()
10589
+ super().__init__()
10574
10590
 
10575
- #
10591
+ self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10576
10592
 
10577
- deploy_tags = DeployTagMap(
10578
- self._make_deploy_time(),
10579
- spec.key(),
10593
+ async def _resolve_installed(self, spec: InterpSpecifier) -> ta.Optional[ta.Tuple[InterpProvider, InterpVersion]]:
10594
+ lst = [
10595
+ (i, si)
10596
+ for i, p in enumerate(self._providers.values())
10597
+ for si in await p.get_installed_versions(spec)
10598
+ if spec.contains(si)
10599
+ ]
10600
+
10601
+ slst = sorted(lst, key=lambda t: (-t[0], t[1].version))
10602
+ if not slst:
10603
+ return None
10604
+
10605
+ bi, bv = slst[-1]
10606
+ bp = list(self._providers.values())[bi]
10607
+ return (bp, bv)
10608
+
10609
+ async def resolve(
10610
+ self,
10611
+ spec: InterpSpecifier,
10612
+ *,
10613
+ install: bool = False,
10614
+ ) -> ta.Optional[Interp]:
10615
+ tup = await self._resolve_installed(spec)
10616
+ if tup is not None:
10617
+ bp, bv = tup
10618
+ return await bp.get_installed_version(bv)
10619
+
10620
+ if not install:
10621
+ return None
10622
+
10623
+ tp = list(self._providers.values())[0] # noqa
10624
+
10625
+ sv = sorted(
10626
+ [s for s in await tp.get_installable_versions(spec) if s in spec],
10627
+ key=lambda s: s.version,
10580
10628
  )
10629
+ if not sv:
10630
+ return None
10581
10631
 
10582
- #
10632
+ bv = sv[-1]
10633
+ return await tp.install_version(bv)
10583
10634
 
10584
- for app in spec.apps:
10585
- app_tags = deploy_tags.add(
10586
- app.app,
10587
- app.key(),
10588
- DeployAppRev(app.git.rev),
10589
- )
10635
+ async def list(self, spec: InterpSpecifier) -> None:
10636
+ print('installed:')
10637
+ for n, p in self._providers.items():
10638
+ lst = [
10639
+ si
10640
+ for si in await p.get_installed_versions(spec)
10641
+ if spec.contains(si)
10642
+ ]
10643
+ if lst:
10644
+ print(f' {n}')
10645
+ for si in lst:
10646
+ print(f' {si}')
10590
10647
 
10591
- await self._apps.prepare_app(
10592
- app,
10593
- app_tags,
10594
- )
10648
+ print()
10649
+
10650
+ print('installable:')
10651
+ for n, p in self._providers.items():
10652
+ lst = [
10653
+ si
10654
+ for si in await p.get_installable_versions(spec)
10655
+ if spec.contains(si)
10656
+ ]
10657
+ if lst:
10658
+ print(f' {n}')
10659
+ for si in lst:
10660
+ print(f' {si}')
10661
+
10662
+
10663
+ DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
10664
+ # pyenv is preferred to system interpreters as it tends to have more support for things like tkinter
10665
+ PyenvInterpProvider(try_update=True),
10666
+
10667
+ RunningInterpProvider(),
10668
+
10669
+ SystemInterpProvider(),
10670
+ ]])
10595
10671
 
10596
10672
 
10597
10673
  ########################################
10598
- # ../remote/inject.py
10674
+ # ../targets/inject.py
10599
10675
 
10600
10676
 
10601
- def bind_remote(
10602
- *,
10603
- remote_config: RemoteConfig,
10604
- ) -> InjectorBindings:
10677
+ def bind_targets() -> InjectorBindings:
10605
10678
  lst: ta.List[InjectorBindingOrBindings] = [
10606
- inj.bind(remote_config),
10607
-
10608
- inj.bind(SubprocessRemoteSpawning, singleton=True),
10609
- inj.bind(RemoteSpawning, to_key=SubprocessRemoteSpawning),
10679
+ inj.bind(LocalManageTargetConnector, singleton=True),
10680
+ inj.bind(DockerManageTargetConnector, singleton=True),
10681
+ inj.bind(SshManageTargetConnector, singleton=True),
10610
10682
 
10611
- inj.bind(PyremoteRemoteExecutionConnector, singleton=True),
10612
- inj.bind(InProcessRemoteExecutionConnector, singleton=True),
10683
+ inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10684
+ inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10613
10685
  ]
10614
10686
 
10615
10687
  #
10616
10688
 
10617
- if (pf := remote_config.payload_file) is not None:
10618
- lst.append(inj.bind(pf, key=RemoteExecutionPayloadFile))
10689
+ def provide_manage_target_connector_map(injector: Injector) -> ManageTargetConnectorMap:
10690
+ return ManageTargetConnectorMap({
10691
+ LocalManageTarget: injector[LocalManageTargetConnector],
10692
+ DockerManageTarget: injector[DockerManageTargetConnector],
10693
+ SshManageTarget: injector[SshManageTargetConnector],
10694
+ })
10695
+ lst.append(inj.bind(provide_manage_target_connector_map, singleton=True))
10619
10696
 
10620
10697
  #
10621
10698
 
@@ -10623,290 +10700,318 @@ def bind_remote(
10623
10700
 
10624
10701
 
10625
10702
  ########################################
10626
- # ../system/inject.py
10703
+ # ../deploy/interp.py
10627
10704
 
10628
10705
 
10629
- def bind_system(
10630
- *,
10631
- system_config: SystemConfig,
10632
- ) -> InjectorBindings:
10633
- lst: ta.List[InjectorBindingOrBindings] = [
10634
- inj.bind(system_config),
10635
- ]
10706
+ ##
10636
10707
 
10637
- #
10638
10708
 
10639
- platform = system_config.platform or detect_system_platform()
10640
- lst.append(inj.bind(platform, key=Platform))
10709
+ @dc.dataclass(frozen=True)
10710
+ class InterpCommand(Command['InterpCommand.Output']):
10711
+ spec: str
10712
+ install: bool = False
10641
10713
 
10642
- #
10714
+ @dc.dataclass(frozen=True)
10715
+ class Output(Command.Output):
10716
+ exe: str
10717
+ version: str
10718
+ opts: InterpOpts
10643
10719
 
10644
- if isinstance(platform, AmazonLinuxPlatform):
10645
- lst.extend([
10646
- inj.bind(YumSystemPackageManager, singleton=True),
10647
- inj.bind(SystemPackageManager, to_key=YumSystemPackageManager),
10648
- ])
10649
10720
 
10650
- elif isinstance(platform, LinuxPlatform):
10651
- lst.extend([
10652
- inj.bind(AptSystemPackageManager, singleton=True),
10653
- inj.bind(SystemPackageManager, to_key=AptSystemPackageManager),
10654
- ])
10721
+ class InterpCommandExecutor(CommandExecutor[InterpCommand, InterpCommand.Output]):
10722
+ async def execute(self, cmd: InterpCommand) -> InterpCommand.Output:
10723
+ i = InterpSpecifier.parse(check.not_none(cmd.spec))
10724
+ o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i, install=cmd.install))
10725
+ return InterpCommand.Output(
10726
+ exe=o.exe,
10727
+ version=str(o.version.version),
10728
+ opts=o.version.opts,
10729
+ )
10655
10730
 
10656
- elif isinstance(platform, DarwinPlatform):
10657
- lst.extend([
10658
- inj.bind(BrewSystemPackageManager, singleton=True),
10659
- inj.bind(SystemPackageManager, to_key=BrewSystemPackageManager),
10660
- ])
10661
10731
 
10662
- #
10732
+ ########################################
10733
+ # ../deploy/venvs.py
10734
+ """
10735
+ TODO:
10736
+ - interp
10737
+ - share more code with pyproject?
10738
+ """
10663
10739
 
10664
- lst.extend([
10665
- bind_command(CheckSystemPackageCommand, CheckSystemPackageCommandExecutor),
10666
- ])
10667
10740
 
10668
- #
10741
+ class DeployVenvManager:
10742
+ def __init__(
10743
+ self,
10744
+ *,
10745
+ atomics: AtomicPathSwapping,
10746
+ ) -> None:
10747
+ super().__init__()
10669
10748
 
10670
- return inj.as_bindings(*lst)
10749
+ self._atomics = atomics
10671
10750
 
10751
+ async def setup_venv(
10752
+ self,
10753
+ spec: DeployVenvSpec,
10754
+ git_dir: str,
10755
+ venv_dir: str,
10756
+ ) -> None:
10757
+ if spec.interp is not None:
10758
+ i = InterpSpecifier.parse(check.not_none(spec.interp))
10759
+ o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i))
10760
+ sys_exe = o.exe
10761
+ else:
10762
+ sys_exe = 'python3'
10672
10763
 
10673
- ########################################
10674
- # ../targets/connection.py
10764
+ #
10675
10765
 
10766
+ # !! NOTE: (most) venvs cannot be relocated, so an atomic swap can't be used. it's up to the path manager to
10767
+ # garbage collect orphaned dirs.
10768
+ await asyncio_subprocesses.check_call(sys_exe, '-m', 'venv', venv_dir)
10676
10769
 
10677
- ##
10770
+ #
10678
10771
 
10772
+ venv_exe = os.path.join(venv_dir, 'bin', 'python3')
10679
10773
 
10680
- class ManageTargetConnector(abc.ABC):
10681
- @abc.abstractmethod
10682
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10683
- raise NotImplementedError
10774
+ #
10684
10775
 
10685
- def _default_python(self, python: ta.Optional[ta.Sequence[str]]) -> ta.Sequence[str]:
10686
- check.not_isinstance(python, str)
10687
- if python is not None:
10688
- return python
10689
- else:
10690
- return ['sh', '-c', get_best_python_sh(), '--']
10776
+ reqs_txt = os.path.join(git_dir, 'requirements.txt')
10691
10777
 
10778
+ if os.path.isfile(reqs_txt):
10779
+ if spec.use_uv:
10780
+ await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
10781
+ pip_cmd = ['-m', 'uv', 'pip']
10782
+ else:
10783
+ pip_cmd = ['-m', 'pip']
10692
10784
 
10693
- ##
10785
+ await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
10694
10786
 
10695
10787
 
10696
- ManageTargetConnectorMap = ta.NewType('ManageTargetConnectorMap', ta.Mapping[ta.Type[ManageTarget], ManageTargetConnector]) # noqa
10788
+ ########################################
10789
+ # ../deploy/apps.py
10697
10790
 
10698
10791
 
10699
- @dc.dataclass(frozen=True)
10700
- class TypeSwitchedManageTargetConnector(ManageTargetConnector):
10701
- connectors: ManageTargetConnectorMap
10792
+ class DeployAppManager(DeployPathOwner):
10793
+ def __init__(
10794
+ self,
10795
+ *,
10796
+ deploy_home: ta.Optional[DeployHome] = None,
10702
10797
 
10703
- def get_connector(self, ty: ta.Type[ManageTarget]) -> ManageTargetConnector:
10704
- for k, v in self.connectors.items():
10705
- if issubclass(ty, k):
10706
- return v
10707
- raise KeyError(ty)
10798
+ conf: DeployConfManager,
10799
+ git: DeployGitManager,
10800
+ venvs: DeployVenvManager,
10801
+ ) -> None:
10802
+ super().__init__()
10708
10803
 
10709
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10710
- return self.get_connector(type(tgt)).connect(tgt)
10804
+ self._deploy_home = deploy_home
10711
10805
 
10806
+ self._conf = conf
10807
+ self._git = git
10808
+ self._venvs = venvs
10712
10809
 
10713
- ##
10810
+ #
10714
10811
 
10812
+ _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
10813
+ _APP_DIR = DeployPath.parse(_APP_DIR_STR)
10715
10814
 
10716
- @dc.dataclass(frozen=True)
10717
- class LocalManageTargetConnector(ManageTargetConnector):
10718
- _local_executor: LocalCommandExecutor
10719
- _in_process_connector: InProcessRemoteExecutionConnector
10720
- _pyremote_connector: PyremoteRemoteExecutionConnector
10721
- _bootstrap: MainBootstrap
10815
+ _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
10816
+ _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
10722
10817
 
10723
- @contextlib.asynccontextmanager
10724
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10725
- lmt = check.isinstance(tgt, LocalManageTarget)
10818
+ _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
10819
+ _CONF_DEPLOY_DIR = DeployPath.parse(f'{_DEPLOY_DIR_STR}conf/@conf/')
10726
10820
 
10727
- if isinstance(lmt, InProcessManageTarget):
10728
- imt = check.isinstance(lmt, InProcessManageTarget)
10821
+ @cached_nullary
10822
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
10823
+ return {
10824
+ self._APP_DIR,
10729
10825
 
10730
- if imt.mode == InProcessManageTarget.Mode.DIRECT:
10731
- yield self._local_executor
10826
+ self._DEPLOY_DIR,
10732
10827
 
10733
- elif imt.mode == InProcessManageTarget.Mode.FAKE_REMOTE:
10734
- async with self._in_process_connector.connect() as rce:
10735
- yield rce
10828
+ self._APP_DEPLOY_LINK,
10829
+ self._CONF_DEPLOY_DIR,
10736
10830
 
10737
- else:
10738
- raise TypeError(imt.mode)
10831
+ *[
10832
+ DeployPath.parse(f'{self._APP_DIR_STR}{sfx}/')
10833
+ for sfx in [
10834
+ 'conf',
10835
+ 'git',
10836
+ 'venv',
10837
+ ]
10838
+ ],
10839
+ }
10739
10840
 
10740
- elif isinstance(lmt, SubprocessManageTarget):
10741
- async with self._pyremote_connector.connect(
10742
- RemoteSpawning.Target(
10743
- python=self._default_python(lmt.python),
10744
- ),
10745
- self._bootstrap,
10746
- ) as rce:
10747
- yield rce
10841
+ #
10748
10842
 
10749
- else:
10750
- raise TypeError(lmt)
10843
+ async def prepare_app(
10844
+ self,
10845
+ spec: DeployAppSpec,
10846
+ tags: DeployTagMap,
10847
+ ) -> None:
10848
+ deploy_home = check.non_empty_str(self._deploy_home)
10751
10849
 
10850
+ def build_path(pth: DeployPath) -> str:
10851
+ return os.path.join(deploy_home, pth.render(tags))
10752
10852
 
10753
- ##
10853
+ app_dir = build_path(self._APP_DIR)
10854
+ deploy_dir = build_path(self._DEPLOY_DIR)
10855
+ app_deploy_link = build_path(self._APP_DEPLOY_LINK)
10754
10856
 
10857
+ #
10755
10858
 
10756
- @dc.dataclass(frozen=True)
10757
- class DockerManageTargetConnector(ManageTargetConnector):
10758
- _pyremote_connector: PyremoteRemoteExecutionConnector
10759
- _bootstrap: MainBootstrap
10859
+ os.makedirs(deploy_dir, exist_ok=True)
10760
10860
 
10761
- @contextlib.asynccontextmanager
10762
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10763
- dmt = check.isinstance(tgt, DockerManageTarget)
10861
+ deploying_link = os.path.join(deploy_home, 'deploys/deploying')
10862
+ if os.path.exists(deploying_link):
10863
+ os.unlink(deploying_link)
10864
+ relative_symlink(
10865
+ deploy_dir,
10866
+ deploying_link,
10867
+ target_is_directory=True,
10868
+ make_dirs=True,
10869
+ )
10764
10870
 
10765
- sh_parts: ta.List[str] = ['docker']
10766
- if dmt.image is not None:
10767
- sh_parts.extend(['run', '-i', dmt.image])
10768
- elif dmt.container_id is not None:
10769
- sh_parts.extend(['exec', '-i', dmt.container_id])
10770
- else:
10771
- raise ValueError(dmt)
10871
+ #
10772
10872
 
10773
- async with self._pyremote_connector.connect(
10774
- RemoteSpawning.Target(
10775
- shell=' '.join(sh_parts),
10776
- python=self._default_python(dmt.python),
10777
- ),
10778
- self._bootstrap,
10779
- ) as rce:
10780
- yield rce
10873
+ os.makedirs(app_dir)
10874
+ relative_symlink(
10875
+ app_dir,
10876
+ app_deploy_link,
10877
+ target_is_directory=True,
10878
+ make_dirs=True,
10879
+ )
10880
+
10881
+ #
10882
+
10883
+ deploy_conf_dir = os.path.join(deploy_dir, 'conf')
10884
+ os.makedirs(deploy_conf_dir, exist_ok=True)
10885
+
10886
+ #
10887
+
10888
+ # def mirror_symlinks(src: str, dst: str) -> None:
10889
+ # def mirror_link(lp: str) -> None:
10890
+ # check.state(os.path.islink(lp))
10891
+ # shutil.copy2(
10892
+ # lp,
10893
+ # os.path.join(dst, os.path.relpath(lp, src)),
10894
+ # follow_symlinks=False,
10895
+ # )
10896
+ #
10897
+ # for dp, dns, fns in os.walk(src, followlinks=False):
10898
+ # for fn in fns:
10899
+ # mirror_link(os.path.join(dp, fn))
10900
+ #
10901
+ # for dn in dns:
10902
+ # dp2 = os.path.join(dp, dn)
10903
+ # if os.path.islink(dp2):
10904
+ # mirror_link(dp2)
10905
+ # else:
10906
+ # os.makedirs(os.path.join(dst, os.path.relpath(dp2, src)))
10781
10907
 
10908
+ current_link = os.path.join(deploy_home, 'deploys/current')
10782
10909
 
10783
- ##
10910
+ # if os.path.exists(current_link):
10911
+ # mirror_symlinks(
10912
+ # os.path.join(current_link, 'conf'),
10913
+ # conf_tag_dir,
10914
+ # )
10915
+ # mirror_symlinks(
10916
+ # os.path.join(current_link, 'apps'),
10917
+ # os.path.join(deploy_dir, 'apps'),
10918
+ # )
10784
10919
 
10920
+ #
10785
10921
 
10786
- @dc.dataclass(frozen=True)
10787
- class SshManageTargetConnector(ManageTargetConnector):
10788
- _pyremote_connector: PyremoteRemoteExecutionConnector
10789
- _bootstrap: MainBootstrap
10922
+ app_git_dir = os.path.join(app_dir, 'git')
10923
+ await self._git.checkout(
10924
+ spec.git,
10925
+ app_git_dir,
10926
+ )
10790
10927
 
10791
- @contextlib.asynccontextmanager
10792
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10793
- smt = check.isinstance(tgt, SshManageTarget)
10928
+ #
10794
10929
 
10795
- sh_parts: ta.List[str] = ['ssh']
10796
- if smt.key_file is not None:
10797
- sh_parts.extend(['-i', smt.key_file])
10798
- addr = check.not_none(smt.host)
10799
- if smt.username is not None:
10800
- addr = f'{smt.username}@{addr}'
10801
- sh_parts.append(addr)
10930
+ if spec.venv is not None:
10931
+ app_venv_dir = os.path.join(app_dir, 'venv')
10932
+ await self._venvs.setup_venv(
10933
+ spec.venv,
10934
+ app_git_dir,
10935
+ app_venv_dir,
10936
+ )
10802
10937
 
10803
- async with self._pyremote_connector.connect(
10804
- RemoteSpawning.Target(
10805
- shell=' '.join(sh_parts),
10806
- shell_quote=True,
10807
- python=self._default_python(smt.python),
10808
- ),
10809
- self._bootstrap,
10810
- ) as rce:
10811
- yield rce
10938
+ #
10812
10939
 
10940
+ if spec.conf is not None:
10941
+ app_conf_dir = os.path.join(app_dir, 'conf')
10942
+ await self._conf.write_app_conf(
10943
+ spec.conf,
10944
+ tags,
10945
+ app_conf_dir,
10946
+ deploy_conf_dir,
10947
+ )
10813
10948
 
10814
- ########################################
10815
- # ../../../omdev/interp/resolvers.py
10949
+ #
10816
10950
 
10951
+ os.replace(deploying_link, current_link)
10817
10952
 
10818
- INTERP_PROVIDER_TYPES_BY_NAME: ta.Mapping[str, ta.Type[InterpProvider]] = {
10819
- cls.name: cls for cls in deep_subclasses(InterpProvider) if abc.ABC not in cls.__bases__ # type: ignore
10820
- }
10821
10953
 
10954
+ ########################################
10955
+ # ../deploy/deploy.py
10822
10956
 
10823
- class InterpResolver:
10824
- def __init__(
10825
- self,
10826
- providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10827
- ) -> None:
10828
- super().__init__()
10829
10957
 
10830
- self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10958
+ DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
10831
10959
 
10832
- async def _resolve_installed(self, spec: InterpSpecifier) -> ta.Optional[ta.Tuple[InterpProvider, InterpVersion]]:
10833
- lst = [
10834
- (i, si)
10835
- for i, p in enumerate(self._providers.values())
10836
- for si in await p.get_installed_versions(spec)
10837
- if spec.contains(si)
10838
- ]
10839
10960
 
10840
- slst = sorted(lst, key=lambda t: (-t[0], t[1].version))
10841
- if not slst:
10842
- return None
10961
+ DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10843
10962
 
10844
- bi, bv = slst[-1]
10845
- bp = list(self._providers.values())[bi]
10846
- return (bp, bv)
10847
10963
 
10848
- async def resolve(
10964
+ class DeployManager:
10965
+ def __init__(
10849
10966
  self,
10850
- spec: InterpSpecifier,
10851
10967
  *,
10852
- install: bool = False,
10853
- ) -> ta.Optional[Interp]:
10854
- tup = await self._resolve_installed(spec)
10855
- if tup is not None:
10856
- bp, bv = tup
10857
- return await bp.get_installed_version(bv)
10968
+ apps: DeployAppManager,
10969
+ paths: DeployPathsManager,
10858
10970
 
10859
- if not install:
10860
- return None
10971
+ utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10972
+ ):
10973
+ super().__init__()
10861
10974
 
10862
- tp = list(self._providers.values())[0] # noqa
10975
+ self._apps = apps
10976
+ self._paths = paths
10863
10977
 
10864
- sv = sorted(
10865
- [s for s in await tp.get_installable_versions(spec) if s in spec],
10866
- key=lambda s: s.version,
10867
- )
10868
- if not sv:
10869
- return None
10978
+ self._utc_clock = utc_clock
10870
10979
 
10871
- bv = sv[-1]
10872
- return await tp.install_version(bv)
10980
+ def _utc_now(self) -> datetime.datetime:
10981
+ if self._utc_clock is not None:
10982
+ return self._utc_clock() # noqa
10983
+ else:
10984
+ return datetime.datetime.now(tz=datetime.timezone.utc) # noqa
10873
10985
 
10874
- async def list(self, spec: InterpSpecifier) -> None:
10875
- print('installed:')
10876
- for n, p in self._providers.items():
10877
- lst = [
10878
- si
10879
- for si in await p.get_installed_versions(spec)
10880
- if spec.contains(si)
10881
- ]
10882
- if lst:
10883
- print(f' {n}')
10884
- for si in lst:
10885
- print(f' {si}')
10986
+ def _make_deploy_time(self) -> DeployTime:
10987
+ return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10886
10988
 
10887
- print()
10989
+ async def run_deploy(
10990
+ self,
10991
+ spec: DeploySpec,
10992
+ ) -> None:
10993
+ self._paths.validate_deploy_paths()
10888
10994
 
10889
- print('installable:')
10890
- for n, p in self._providers.items():
10891
- lst = [
10892
- si
10893
- for si in await p.get_installable_versions(spec)
10894
- if spec.contains(si)
10895
- ]
10896
- if lst:
10897
- print(f' {n}')
10898
- for si in lst:
10899
- print(f' {si}')
10995
+ #
10900
10996
 
10997
+ deploy_tags = DeployTagMap(
10998
+ self._make_deploy_time(),
10999
+ spec.key(),
11000
+ )
10901
11001
 
10902
- DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
10903
- # pyenv is preferred to system interpreters as it tends to have more support for things like tkinter
10904
- PyenvInterpProvider(try_update=True),
11002
+ #
10905
11003
 
10906
- RunningInterpProvider(),
11004
+ for app in spec.apps:
11005
+ app_tags = deploy_tags.add(
11006
+ app.app,
11007
+ app.key(),
11008
+ DeployAppRev(app.git.rev),
11009
+ )
10907
11010
 
10908
- SystemInterpProvider(),
10909
- ]])
11011
+ await self._apps.prepare_app(
11012
+ app,
11013
+ app_tags,
11014
+ )
10910
11015
 
10911
11016
 
10912
11017
  ########################################
@@ -10937,65 +11042,6 @@ class DeployCommandExecutor(CommandExecutor[DeployCommand, DeployCommand.Output]
10937
11042
  return DeployCommand.Output()
10938
11043
 
10939
11044
 
10940
- ########################################
10941
- # ../targets/inject.py
10942
-
10943
-
10944
- def bind_targets() -> InjectorBindings:
10945
- lst: ta.List[InjectorBindingOrBindings] = [
10946
- inj.bind(LocalManageTargetConnector, singleton=True),
10947
- inj.bind(DockerManageTargetConnector, singleton=True),
10948
- inj.bind(SshManageTargetConnector, singleton=True),
10949
-
10950
- inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10951
- inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10952
- ]
10953
-
10954
- #
10955
-
10956
- def provide_manage_target_connector_map(injector: Injector) -> ManageTargetConnectorMap:
10957
- return ManageTargetConnectorMap({
10958
- LocalManageTarget: injector[LocalManageTargetConnector],
10959
- DockerManageTarget: injector[DockerManageTargetConnector],
10960
- SshManageTarget: injector[SshManageTargetConnector],
10961
- })
10962
- lst.append(inj.bind(provide_manage_target_connector_map, singleton=True))
10963
-
10964
- #
10965
-
10966
- return inj.as_bindings(*lst)
10967
-
10968
-
10969
- ########################################
10970
- # ../deploy/interp.py
10971
-
10972
-
10973
- ##
10974
-
10975
-
10976
- @dc.dataclass(frozen=True)
10977
- class InterpCommand(Command['InterpCommand.Output']):
10978
- spec: str
10979
- install: bool = False
10980
-
10981
- @dc.dataclass(frozen=True)
10982
- class Output(Command.Output):
10983
- exe: str
10984
- version: str
10985
- opts: InterpOpts
10986
-
10987
-
10988
- class InterpCommandExecutor(CommandExecutor[InterpCommand, InterpCommand.Output]):
10989
- async def execute(self, cmd: InterpCommand) -> InterpCommand.Output:
10990
- i = InterpSpecifier.parse(check.not_none(cmd.spec))
10991
- o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i, install=cmd.install))
10992
- return InterpCommand.Output(
10993
- exe=o.exe,
10994
- version=str(o.version.version),
10995
- opts=o.version.opts,
10996
- )
10997
-
10998
-
10999
11045
  ########################################
11000
11046
  # ../deploy/inject.py
11001
11047
 
@@ -11144,6 +11190,8 @@ def main_bootstrap(bs: MainBootstrap) -> Injector:
11144
11190
 
11145
11191
  @dc.dataclass(frozen=True)
11146
11192
  class ManageConfig:
11193
+ deploy_home: ta.Optional[str] = None
11194
+
11147
11195
  targets: ta.Optional[ta.Mapping[str, ManageTarget]] = None
11148
11196
 
11149
11197
 
@@ -11178,7 +11226,8 @@ class MainCli(ArgparseCli):
11178
11226
  argparse_arg('--deploy-home'),
11179
11227
 
11180
11228
  argparse_arg('target'),
11181
- argparse_arg('command', nargs='+'),
11229
+ argparse_arg('-f', '--command-file', action='append'),
11230
+ argparse_arg('command', nargs='*'),
11182
11231
  )
11183
11232
  async def run(self) -> None:
11184
11233
  bs = MainBootstrap(
@@ -11189,7 +11238,7 @@ class MainCli(ArgparseCli):
11189
11238
  ),
11190
11239
 
11191
11240
  deploy_config=DeployConfig(
11192
- deploy_home=self.args.deploy_home,
11241
+ deploy_home=self.args.deploy_home or self.config().deploy_home,
11193
11242
  ),
11194
11243
 
11195
11244
  remote_config=RemoteConfig(
@@ -11224,13 +11273,19 @@ class MainCli(ArgparseCli):
11224
11273
  #
11225
11274
 
11226
11275
  cmds: ta.List[Command] = []
11276
+
11227
11277
  cmd: Command
11228
- for c in self.args.command:
11278
+
11279
+ for c in self.args.command or []:
11229
11280
  if not c.startswith('{'):
11230
11281
  c = json.dumps({c: {}})
11231
11282
  cmd = msh.unmarshal_obj(json.loads(c), Command)
11232
11283
  cmds.append(cmd)
11233
11284
 
11285
+ for cf in self.args.command_file or []:
11286
+ cmd = read_config_file(cf, Command, msh=msh)
11287
+ cmds.append(cmd)
11288
+
11234
11289
  #
11235
11290
 
11236
11291
  async with injector[ManageTargetConnector].connect(tgt) as ce: