ominfra 0.0.0.dev174__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,361 +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)
6976
-
6977
- @property
6978
- def tag(self) -> ta.Type[DeployTag]:
6979
- return DEPLOY_TAGS_BY_NAME[self.name]
6980
-
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
6986
-
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)
6987
6776
 
6988
- @dc.dataclass(frozen=True)
6989
- class DelimiterDeployPathNamePart(DeployPathNamePart):
6990
- delimiter: str
6777
+ #
6991
6778
 
6992
- def __post_init__(self) -> None:
6993
- check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
6779
+ tag_name: ta.ClassVar[str]
6780
+ tag_kwarg: ta.ClassVar[str]
6994
6781
 
6995
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
6996
- return self.delimiter
6782
+ def __init_subclass__(cls, **kwargs: ta.Any) -> None:
6783
+ super().__init_subclass__(**kwargs)
6997
6784
 
6785
+ if abc.ABC in cls.__bases__:
6786
+ return
6998
6787
 
6999
- @dc.dataclass(frozen=True)
7000
- class ConstDeployPathNamePart(DeployPathNamePart):
7001
- const: str
6788
+ for b in cls.__bases__:
6789
+ if issubclass(b, DeployTag):
6790
+ check.in_(abc.ABC, b.__bases__)
7002
6791
 
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)
6792
+ check.non_empty_str(tn := cls.tag_name)
6793
+ check.equal(tn, tn.lower().strip())
6794
+ check.not_in('_', tn)
7007
6795
 
7008
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7009
- return self.const
6796
+ check.state(not hasattr(cls, 'tag_kwarg'))
6797
+ cls.tag_kwarg = tn.replace('-', '_')
7010
6798
 
7011
6799
 
7012
- @dc.dataclass(frozen=True)
7013
- class DeployPathName(DeployPathRenderable):
7014
- parts: ta.Sequence[DeployPathNamePart]
6800
+ ##
7015
6801
 
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}')
7022
6802
 
7023
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7024
- return ''.join(p.render(tags) for p in self.parts)
6803
+ _DEPLOY_TAGS: ta.Set[ta.Type[DeployTag]] = set()
6804
+ DEPLOY_TAGS: ta.AbstractSet[ta.Type[DeployTag]] = _DEPLOY_TAGS
7025
6805
 
7026
- @classmethod
7027
- def parse(cls, s: str) -> 'DeployPathName':
7028
- check.non_empty_str(s)
7029
- check.not_in('/', s)
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
7030
6808
 
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)
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
7042
6811
 
7043
- return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
7044
6812
 
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)
7045
6816
 
7046
- ##
6817
+ _DEPLOY_TAGS.add(cls)
6818
+ _DEPLOY_TAGS_BY_NAME[cls.tag_name] = cls
6819
+ _DEPLOY_TAGS_BY_KWARG[cls.tag_kwarg] = cls
7047
6820
 
6821
+ register_type_obj_marshaler(cls, SingleFieldObjMarshaler(cls, 's'))
7048
6822
 
7049
- @dc.dataclass(frozen=True)
7050
- class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7051
- name: DeployPathName
6823
+ return cls
7052
6824
 
7053
- @property
7054
- @abc.abstractmethod
7055
- def kind(self) -> DeployPathKind:
7056
- raise NotImplementedError
7057
6825
 
7058
- def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7059
- return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
6826
+ ##
7060
6827
 
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
6828
 
7068
- n = DeployPathName.parse(s)
7069
- if is_dir:
7070
- return DirDeployPathPart(n)
7071
- else:
7072
- return FileDeployPathPart(n)
6829
+ @_register_deploy_tag
6830
+ class DeployTime(DeployTag):
6831
+ tag_name: ta.ClassVar[str] = 'time'
7073
6832
 
7074
6833
 
7075
- class DirDeployPathPart(DeployPathPart):
7076
- @property
7077
- def kind(self) -> DeployPathKind:
7078
- return 'dir'
6834
+ ##
7079
6835
 
7080
6836
 
7081
- class FileDeployPathPart(DeployPathPart):
7082
- @property
7083
- def kind(self) -> DeployPathKind:
7084
- return 'file'
6837
+ class NameDeployTag(DeployTag, abc.ABC): # noqa
6838
+ pass
7085
6839
 
7086
6840
 
7087
- #
6841
+ @_register_deploy_tag
6842
+ class DeployApp(NameDeployTag):
6843
+ tag_name: ta.ClassVar[str] = 'app'
7088
6844
 
7089
6845
 
7090
- @dc.dataclass(frozen=True)
7091
- class DeployPath:
7092
- parts: ta.Sequence[DeployPathPart]
6846
+ @_register_deploy_tag
6847
+ class DeployConf(NameDeployTag):
6848
+ tag_name: ta.ClassVar[str] = 'conf'
7093
6849
 
7094
- @property
7095
- def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7096
- for p in self.parts:
7097
- yield from p.name.parts
7098
6850
 
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')
6851
+ ##
7104
6852
 
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
6853
 
7113
- @property
7114
- def kind(self) -> ta.Literal['file', 'dir']:
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(abc.ABC): # 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
- def __post_init__(self) -> None:
7220
- check_valid_deploy_spec_path(self.src)
7221
- if '/' in self.src:
7222
- check.equal(self.src.count('/'), 1)
7223
-
7224
-
7225
- class CurrentOnlyDeployAppConfLink(DeployAppConfLink):
7226
- pass
7227
-
7228
-
7229
- class AllActiveDeployAppConfLink(DeployAppConfLink):
7230
- pass
7231
-
7232
-
7233
- #
7234
-
7235
-
7236
- @dc.dataclass(frozen=True)
7237
- class DeployAppConfSpec:
7238
- files: ta.Optional[ta.Sequence[DeployAppConfFile]] = None
7239
-
7240
- links: ta.Optional[ta.Sequence[DeployAppConfLink]] = None
7241
-
7242
- def __post_init__(self) -> None:
7243
- if self.files:
7244
- seen: ta.Set[str] = set()
7245
- for f in self.files:
7246
- check.not_in(f.path, seen)
7247
- 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)
7248
6902
 
6903
+ self._dct = dct
6904
+ self._tup = tuple(sorted((type(t).tag_kwarg, t.s) for t in dct.values()))
7249
6905
 
7250
- ##
6906
+ #
7251
6907
 
6908
+ def add(self, *args: ta.Any, **kwargs: ta.Any) -> 'DeployTagMap':
6909
+ return DeployTagMap(
6910
+ *self,
6911
+ *args,
6912
+ **kwargs,
6913
+ )
7252
6914
 
7253
- @dc.dataclass(frozen=True)
7254
- class DeployAppSpec(DeploySpecKeyed[DeployAppKey]):
7255
- 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
+ }
7256
6920
 
7257
- git: DeployGitSpec
6921
+ return DeployTagMap(*[
6922
+ t
6923
+ for t in self._dct.values()
6924
+ if t not in dcs
6925
+ ])
7258
6926
 
7259
- venv: ta.Optional[DeployVenvSpec] = None
6927
+ #
7260
6928
 
7261
- 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)})'
7262
6931
 
7263
- # @ta.override
7264
- def key(self) -> DeployAppKey:
7265
- return DeployAppKey(self._key_str())
6932
+ def __hash__(self) -> int:
6933
+ return hash(self._tup)
7266
6934
 
6935
+ def __eq__(self, other: object) -> bool:
6936
+ if isinstance(other, DeployTagMap):
6937
+ return self._tup == other._tup
6938
+ else:
6939
+ return NotImplemented
7267
6940
 
7268
- ##
6941
+ #
7269
6942
 
6943
+ def __len__(self) -> int:
6944
+ return len(self._dct)
7270
6945
 
7271
- @dc.dataclass(frozen=True)
7272
- class DeploySpec(DeploySpecKeyed[DeployKey]):
7273
- apps: ta.Sequence[DeployAppSpec]
6946
+ def __iter__(self) -> ta.Iterator[DeployTag]:
6947
+ return iter(self._dct.values())
7274
6948
 
7275
- def __post_init__(self) -> None:
7276
- seen: ta.Set[DeployApp] = set()
7277
- for a in self.apps:
7278
- if a.app in seen:
7279
- raise KeyError(a.app)
7280
- 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)
7281
6956
 
7282
- # @ta.override
7283
- def key(self) -> DeployKey:
7284
- 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)
7285
6964
 
7286
6965
 
7287
6966
  ########################################
@@ -7869,290 +7548,424 @@ class LocalCommandExecutor(CommandExecutor):
7869
7548
 
7870
7549
 
7871
7550
  ########################################
7872
- # ../deploy/conf.py
7551
+ # ../deploy/paths/paths.py
7873
7552
  """
7874
7553
  TODO:
7875
- - @conf DeployPathPlaceholder? :|
7876
- - post-deploy: remove any dir_links not present in new spec
7877
- - * only if succeeded * - otherwise, remove any dir_links present in new spec but not previously present?
7878
- - no such thing as 'previously present'.. build a 'deploy state' and pass it back?
7879
- - ** whole thing can be atomic **
7880
- - 1) new atomic temp dir
7881
- - 2) for each subdir not needing modification, hardlink into temp dir
7882
- - 3) for each subdir needing modification, new subdir, hardlink all files not needing modification
7883
- - 4) write (or if deleting, omit) new files
7884
- - 5) swap top level
7885
- - ** whole deploy can be atomic(-ish) - do this for everything **
7886
- - just a '/deploy/current' dir
7887
- - some things (venvs) cannot be moved, thus the /deploy/venvs dir
7888
- - ** ensure (enforce) equivalent relpath nesting
7554
+ - run/{.pid,.sock}
7555
+ - logs/...
7556
+ - current symlink
7557
+ - conf/{nginx,supervisor}
7558
+ - env/?
7559
+ - apps/<app>/shared
7889
7560
  """
7890
7561
 
7891
7562
 
7892
- class DeployConfManager:
7893
- def __init__(
7894
- self,
7895
- *,
7896
- deploy_home: ta.Optional[DeployHome] = None,
7897
- ) -> None:
7898
- super().__init__()
7563
+ ##
7899
7564
 
7900
- self._deploy_home = deploy_home
7901
7565
 
7902
- #
7566
+ class DeployPathError(Exception):
7567
+ pass
7903
7568
 
7904
- async def _write_app_conf_file(
7905
- self,
7906
- acf: DeployAppConfFile,
7907
- app_conf_dir: str,
7908
- ) -> None:
7909
- conf_file = os.path.join(app_conf_dir, acf.path)
7910
- check.arg(is_path_in_dir(app_conf_dir, conf_file))
7911
7569
 
7912
- os.makedirs(os.path.dirname(conf_file), exist_ok=True)
7570
+ class DeployPathRenderable(abc.ABC):
7571
+ @abc.abstractmethod
7572
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7573
+ raise NotImplementedError
7913
7574
 
7914
- with open(conf_file, 'w') as f: # noqa
7915
- f.write(acf.body)
7916
7575
 
7917
- #
7576
+ ##
7918
7577
 
7919
- class _ComputedConfLink(ta.NamedTuple):
7920
- is_dir: bool
7921
- link_src: str
7922
- link_dst: str
7923
7578
 
7924
- _UNIQUE_LINK_NAME_STR = '@app--@time--@app-key'
7925
- _UNIQUE_LINK_NAME = DeployPath.parse(_UNIQUE_LINK_NAME_STR)
7579
+ class DeployPathNamePart(DeployPathRenderable, abc.ABC):
7580
+ @classmethod
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)
7926
7589
 
7927
- def _compute_app_conf_link_dst(
7928
- self,
7929
- link: DeployAppConfLink,
7930
- tags: DeployTagMap,
7931
- app_conf_dir: str,
7932
- conf_link_dir: str,
7933
- ) -> _ComputedConfLink:
7934
- link_src = os.path.join(app_conf_dir, link.src)
7935
- check.arg(is_path_in_dir(app_conf_dir, link_src))
7936
7590
 
7937
- #
7591
+ @dc.dataclass(frozen=True)
7592
+ class TagDeployPathNamePart(DeployPathNamePart):
7593
+ name: str
7938
7594
 
7939
- if (is_dir := link.src.endswith('/')):
7940
- # @conf/ - links a directory in root of app conf dir to conf/@conf/@dst/
7941
- check.arg(link.src.count('/') == 1)
7942
- link_dst_pfx = link.src
7943
- link_dst_sfx = ''
7595
+ def __post_init__(self) -> None:
7596
+ check.in_(self.name, DEPLOY_TAGS_BY_NAME)
7944
7597
 
7945
- elif '/' in link.src:
7946
- # @conf/file - links a single file in a single subdir to conf/@conf/@dst--file
7947
- d, f = os.path.split(link.src)
7948
- # TODO: check filename :|
7949
- link_dst_pfx = d + '/'
7950
- link_dst_sfx = DEPLOY_TAG_SEPARATOR + f
7598
+ @property
7599
+ def tag(self) -> ta.Type[DeployTag]:
7600
+ return DEPLOY_TAGS_BY_NAME[self.name]
7951
7601
 
7952
- else: # noqa
7953
- # @conf(.ext)* - links a single file in root of app conf dir to conf/@conf/@dst(.ext)*
7954
- if '.' in link.src:
7955
- l, _, r = link.src.partition('.')
7956
- link_dst_pfx = l + '/'
7957
- link_dst_sfx = '.' + r
7958
- else:
7959
- link_dst_pfx = link.src + '/'
7960
- link_dst_sfx = ''
7602
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7603
+ if tags is not None:
7604
+ return tags[self.tag].s
7605
+ else:
7606
+ return DEPLOY_TAG_SIGIL + self.name
7961
7607
 
7962
- #
7963
7608
 
7964
- if isinstance(link, CurrentOnlyDeployAppConfLink):
7965
- link_dst_mid = str(tags[DeployApp].s)
7966
- elif isinstance(link, AllActiveDeployAppConfLink):
7967
- link_dst_mid = self._UNIQUE_LINK_NAME.render(tags)
7968
- else:
7969
- raise TypeError(link)
7609
+ @dc.dataclass(frozen=True)
7610
+ class DelimiterDeployPathNamePart(DeployPathNamePart):
7611
+ delimiter: str
7970
7612
 
7971
- #
7613
+ def __post_init__(self) -> None:
7614
+ check.in_(self.delimiter, DEPLOY_TAG_DELIMITERS)
7972
7615
 
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)
7616
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7617
+ return self.delimiter
7979
7618
 
7980
- return DeployConfManager._ComputedConfLink(
7981
- is_dir=is_dir,
7982
- link_src=link_src,
7983
- link_dst=link_dst,
7984
- )
7985
7619
 
7986
- async def _make_app_conf_link(
7987
- self,
7988
- link: DeployAppConfLink,
7989
- tags: DeployTagMap,
7990
- app_conf_dir: str,
7991
- conf_link_dir: str,
7992
- ) -> None:
7993
- comp = self._compute_app_conf_link_dst(
7994
- link,
7995
- tags,
7996
- app_conf_dir,
7997
- conf_link_dir,
7998
- )
7620
+ @dc.dataclass(frozen=True)
7621
+ class ConstDeployPathNamePart(DeployPathNamePart):
7622
+ const: str
7999
7623
 
8000
- #
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)
8001
7628
 
8002
- check.arg(is_path_in_dir(app_conf_dir, comp.link_src))
8003
- check.arg(is_path_in_dir(conf_link_dir, comp.link_dst))
7629
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7630
+ return self.const
8004
7631
 
8005
- if comp.is_dir:
8006
- check.arg(os.path.isdir(comp.link_src))
8007
- else:
8008
- check.arg(os.path.isfile(comp.link_src))
8009
7632
 
8010
- #
7633
+ @dc.dataclass(frozen=True)
7634
+ class DeployPathName(DeployPathRenderable):
7635
+ parts: ta.Sequence[DeployPathNamePart]
8011
7636
 
8012
- relative_symlink( # noqa
8013
- comp.link_src,
8014
- comp.link_dst,
8015
- target_is_directory=comp.is_dir,
8016
- make_dirs=True,
8017
- )
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}')
8018
7643
 
8019
- #
7644
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7645
+ return ''.join(p.render(tags) for p in self.parts)
8020
7646
 
8021
- async def write_app_conf(
8022
- self,
8023
- spec: DeployAppConfSpec,
8024
- tags: DeployTagMap,
8025
- app_conf_dir: str,
8026
- conf_link_dir: str,
8027
- ) -> None:
8028
- for acf in spec.files or []:
8029
- await self._write_app_conf_file(
8030
- acf,
8031
- app_conf_dir,
8032
- )
7647
+ @classmethod
7648
+ def parse(cls, s: str) -> 'DeployPathName':
7649
+ check.non_empty_str(s)
7650
+ check.not_in('/', s)
8033
7651
 
8034
- #
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)
8035
7663
 
8036
- for link in spec.links or []:
8037
- await self._make_app_conf_link(
8038
- link,
8039
- tags,
8040
- app_conf_dir,
8041
- conf_link_dir,
8042
- )
7664
+ return cls(tuple(DeployPathNamePart.parse(p) for p in ps))
8043
7665
 
8044
7666
 
8045
- ########################################
8046
- # ../deploy/paths/owners.py
7667
+ ##
8047
7668
 
8048
7669
 
8049
- class DeployPathOwner(abc.ABC):
7670
+ @dc.dataclass(frozen=True)
7671
+ class DeployPathPart(DeployPathRenderable, abc.ABC): # noqa
7672
+ name: DeployPathName
7673
+
7674
+ @property
8050
7675
  @abc.abstractmethod
8051
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
7676
+ def kind(self) -> DeployPathKind:
8052
7677
  raise NotImplementedError
8053
7678
 
7679
+ def render(self, tags: ta.Optional[DeployTagMap] = None) -> str:
7680
+ return self.name.render(tags) + ('/' if self.kind == 'dir' else '')
8054
7681
 
8055
- 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)
8056
7688
 
7689
+ n = DeployPathName.parse(s)
7690
+ if is_dir:
7691
+ return DirDeployPathPart(n)
7692
+ else:
7693
+ return FileDeployPathPart(n)
8057
7694
 
8058
- class SingleDirDeployPathOwner(DeployPathOwner, abc.ABC):
8059
- def __init__(
8060
- self,
8061
- *args: ta.Any,
8062
- owned_dir: str,
8063
- deploy_home: ta.Optional[DeployHome],
8064
- **kwargs: ta.Any,
8065
- ) -> None:
8066
- super().__init__(*args, **kwargs)
8067
7695
 
8068
- check.not_in('/', owned_dir)
8069
- self._owned_dir: str = check.non_empty_str(owned_dir)
7696
+ class DirDeployPathPart(DeployPathPart):
7697
+ @property
7698
+ def kind(self) -> DeployPathKind:
7699
+ return 'dir'
8070
7700
 
8071
- self._deploy_home = deploy_home
8072
7701
 
8073
- self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
7702
+ class FileDeployPathPart(DeployPathPart):
7703
+ @property
7704
+ def kind(self) -> DeployPathKind:
7705
+ return 'file'
8074
7706
 
8075
- @cached_nullary
8076
- def _dir(self) -> str:
8077
- return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
8078
7707
 
8079
- @cached_nullary
8080
- def _make_dir(self) -> str:
8081
- if not os.path.isdir(d := self._dir()):
8082
- os.makedirs(d, exist_ok=True)
8083
- return d
7708
+ ##
8084
7709
 
8085
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
8086
- return self._owned_deploy_paths
8087
7710
 
7711
+ @dc.dataclass(frozen=True)
7712
+ class DeployPath:
7713
+ parts: ta.Sequence[DeployPathPart]
8088
7714
 
8089
- ########################################
8090
- # ../remote/execution.py
8091
- """
8092
- TODO:
8093
- - sequence all messages
8094
- """
7715
+ @property
7716
+ def name_parts(self) -> ta.Iterator[DeployPathNamePart]:
7717
+ for p in self.parts:
7718
+ yield from p.name.parts
8095
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')
8096
7725
 
8097
- ##
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
8098
7733
 
7734
+ @property
7735
+ def kind(self) -> DeployPathKind:
7736
+ return self.parts[-1].kind
8099
7737
 
8100
- class _RemoteProtocol:
8101
- class Message(abc.ABC): # noqa
8102
- async def send(self, chan: RemoteChannel) -> None:
8103
- 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])
8104
7740
 
8105
- @classmethod
8106
- async def recv(cls: ta.Type[T], chan: RemoteChannel) -> ta.Optional[T]:
8107
- 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))
8108
7746
 
8109
- #
8110
7747
 
8111
- class Request(Message, abc.ABC): # noqa
8112
- pass
7748
+ ########################################
7749
+ # ../deploy/specs.py
8113
7750
 
8114
- @dc.dataclass(frozen=True)
8115
- class CommandRequest(Request):
8116
- seq: int
8117
- cmd: Command
8118
7751
 
8119
- @dc.dataclass(frozen=True)
8120
- class PingRequest(Request):
8121
- time: float
7752
+ ##
8122
7753
 
8123
- #
8124
7754
 
8125
- class Response(Message, abc.ABC): # noqa
8126
- 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
8127
7761
 
8128
- @dc.dataclass(frozen=True)
8129
- class LogResponse(Response):
8130
- s: str
8131
7762
 
8132
- @dc.dataclass(frozen=True)
8133
- class CommandResponse(Response):
8134
- seq: int
8135
- 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]
8136
7767
 
8137
- @dc.dataclass(frozen=True)
8138
- class PingResponse(Response):
8139
- time: float
7768
+ @abc.abstractmethod
7769
+ def key(self) -> KeyDeployTagT:
7770
+ raise NotImplementedError
8140
7771
 
8141
7772
 
8142
7773
  ##
8143
7774
 
8144
7775
 
8145
- class _RemoteLogHandler(logging.Handler):
8146
- def __init__(
8147
- self,
8148
- chan: RemoteChannel,
8149
- loop: ta.Any = None,
8150
- ) -> None:
8151
- super().__init__()
8152
-
8153
- self._chan = chan
8154
- self._loop = loop
8155
-
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,
7963
+ ) -> None:
7964
+ super().__init__()
7965
+
7966
+ self._chan = chan
7967
+ self._loop = loop
7968
+
8156
7969
  def emit(self, record):
8157
7970
  msg = self.format(record)
8158
7971
 
@@ -8873,266 +8686,258 @@ class SubprocessCommandExecutor(CommandExecutor[SubprocessCommand, SubprocessCom
8873
8686
 
8874
8687
 
8875
8688
  ########################################
8876
- # ../deploy/git.py
8689
+ # ../deploy/conf.py
8877
8690
  """
8878
8691
  TODO:
8879
- - 'repos'?
8880
-
8881
- git/github.com/wrmsr/omlish <- bootstrap repo
8882
- - shallow clone off bootstrap into /apps
8883
-
8884
- 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
8885
8706
  """
8886
8707
 
8887
8708
 
8888
- ##
8889
-
8890
-
8891
- class DeployGitManager(SingleDirDeployPathOwner):
8709
+ class DeployConfManager:
8892
8710
  def __init__(
8893
8711
  self,
8894
8712
  *,
8895
8713
  deploy_home: ta.Optional[DeployHome] = None,
8896
- atomics: AtomicPathSwapping,
8897
8714
  ) -> None:
8898
- super().__init__(
8899
- owned_dir='git',
8900
- deploy_home=deploy_home,
8901
- )
8715
+ super().__init__()
8902
8716
 
8903
- self._atomics = atomics
8717
+ self._deploy_home = deploy_home
8904
8718
 
8905
- self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
8719
+ #
8906
8720
 
8907
- class RepoDir:
8908
- def __init__(
8909
- self,
8910
- git: 'DeployGitManager',
8911
- repo: DeployGitRepo,
8912
- ) -> None:
8913
- 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))
8914
8728
 
8915
- self._git = git
8916
- self._repo = repo
8917
- self._dir = os.path.join(
8918
- self._git._make_dir(), # noqa
8919
- check.non_empty_str(repo.host),
8920
- check.non_empty_str(repo.path),
8921
- )
8729
+ os.makedirs(os.path.dirname(conf_file), exist_ok=True)
8922
8730
 
8923
- @property
8924
- def repo(self) -> DeployGitRepo:
8925
- return self._repo
8731
+ with open(conf_file, 'w') as f: # noqa
8732
+ f.write(acf.body)
8926
8733
 
8927
- @property
8928
- def url(self) -> str:
8929
- if self._repo.username is not None:
8930
- return f'{self._repo.username}@{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))
8755
+
8756
+ #
8757
+
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
8931
8780
  else:
8932
- return f'https://{self._repo.host}/{self._repo.path}'
8781
+ conf = DeployConf(link.src)
8782
+ link_dst_pfx = link.src + '/'
8783
+ link_dst_sfx = ''
8933
8784
 
8934
8785
  #
8935
8786
 
8936
- async def _call(self, *cmd: str) -> None:
8937
- await asyncio_subprocesses.check_call(
8938
- *cmd,
8939
- cwd=self._dir,
8940
- )
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)
8941
8793
 
8942
8794
  #
8943
8795
 
8944
- @async_cached_nullary
8945
- async def init(self) -> None:
8946
- os.makedirs(self._dir, exist_ok=True)
8947
- if os.path.exists(os.path.join(self._dir, '.git')):
8948
- return
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)
8949
8802
 
8950
- await self._call('git', 'init')
8951
- await self._call('git', 'remote', 'add', 'origin', self.url)
8803
+ return DeployConfManager._ComputedConfLink(
8804
+ conf=conf,
8805
+ is_dir=is_dir,
8806
+ link_src=link_src,
8807
+ link_dst=link_dst,
8808
+ )
8952
8809
 
8953
- async def fetch(self, rev: DeployRev) -> None:
8954
- await self.init()
8955
- await self._call('git', 'fetch', '--depth=1', 'origin', rev)
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
+ )
8956
8823
 
8957
8824
  #
8958
8825
 
8959
- async def checkout(self, spec: DeployGitSpec, dst_dir: str) -> None:
8960
- check.state(not os.path.exists(dst_dir))
8961
- with self._git._atomics.begin_atomic_path_swap( # noqa
8962
- 'dir',
8963
- dst_dir,
8964
- auto_commit=True,
8965
- make_dirs=True,
8966
- ) as dst_swap:
8967
- 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))
8968
8828
 
8969
- dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
8970
- 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))
8971
8833
 
8972
- await dst_call('git', 'remote', 'add', 'local', self._dir)
8973
- await dst_call('git', 'fetch', '--depth=1', 'local', spec.rev)
8974
- await dst_call('git', 'checkout', spec.rev, *(spec.subtrees or []))
8834
+ #
8975
8835
 
8976
- def get_repo_dir(self, repo: DeployGitRepo) -> RepoDir:
8977
- try:
8978
- return self._repo_dirs[repo]
8979
- except KeyError:
8980
- repo_dir = self._repo_dirs[repo] = DeployGitManager.RepoDir(self, repo)
8981
- 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
+ )
8982
8842
 
8983
- async def checkout(
8843
+ #
8844
+
8845
+ async def write_app_conf(
8984
8846
  self,
8985
- spec: DeployGitSpec,
8986
- dst_dir: str,
8847
+ spec: DeployAppConfSpec,
8848
+ tags: DeployTagMap,
8849
+ app_conf_dir: str,
8850
+ conf_link_dir: str,
8987
8851
  ) -> None:
8988
- 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
+ )
8989
8867
 
8990
8868
 
8991
8869
  ########################################
8992
- # ../deploy/paths/manager.py
8870
+ # ../deploy/paths/owners.py
8993
8871
 
8994
8872
 
8995
- 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):
8996
8883
  def __init__(
8997
8884
  self,
8998
- *,
8885
+ *args: ta.Any,
8886
+ owned_dir: str,
8999
8887
  deploy_home: ta.Optional[DeployHome],
9000
- deploy_path_owners: DeployPathOwners,
8888
+ **kwargs: ta.Any,
9001
8889
  ) -> None:
9002
- 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)
9003
8894
 
9004
8895
  self._deploy_home = deploy_home
9005
- self._deploy_path_owners = deploy_path_owners
8896
+
8897
+ self._owned_deploy_paths = frozenset([DeployPath.parse(self._owned_dir + '/')])
9006
8898
 
9007
8899
  @cached_nullary
9008
- def owners_by_path(self) -> ta.Mapping[DeployPath, DeployPathOwner]:
9009
- dct: ta.Dict[DeployPath, DeployPathOwner] = {}
9010
- for o in self._deploy_path_owners:
9011
- for p in o.get_owned_deploy_paths():
9012
- if p in dct:
9013
- raise DeployPathError(f'Duplicate deploy path owner: {p}')
9014
- dct[p] = o
9015
- return dct
8900
+ def _dir(self) -> str:
8901
+ return os.path.join(check.non_empty_str(self._deploy_home), self._owned_dir)
9016
8902
 
9017
- def validate_deploy_paths(self) -> None:
9018
- 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
9019
8911
 
9020
8912
 
9021
8913
  ########################################
9022
- # ../deploy/tmp.py
8914
+ # ../remote/_main.py
9023
8915
 
9024
8916
 
9025
- class DeployTmpManager(
9026
- SingleDirDeployPathOwner,
9027
- AtomicPathSwapping,
9028
- ):
9029
- def __init__(
9030
- self,
9031
- *,
9032
- deploy_home: ta.Optional[DeployHome] = None,
9033
- ) -> None:
9034
- super().__init__(
9035
- owned_dir='tmp',
9036
- deploy_home=deploy_home,
9037
- )
8917
+ ##
9038
8918
 
9039
- @cached_nullary
9040
- def _swapping(self) -> AtomicPathSwapping:
9041
- return TempDirAtomicPathSwapping(
9042
- temp_dir=self._make_dir(),
9043
- root_dir=check.non_empty_str(self._deploy_home),
9044
- )
9045
8919
 
9046
- def begin_atomic_path_swap(
9047
- self,
9048
- kind: AtomicPathSwapKind,
9049
- dst_path: str,
9050
- **kwargs: ta.Any,
9051
- ) -> AtomicPathSwap:
9052
- return self._swapping().begin_atomic_path_swap(
9053
- kind,
9054
- dst_path,
9055
- **kwargs,
9056
- )
8920
+ class _RemoteExecutionLogHandler(logging.Handler):
8921
+ def __init__(self, fn: ta.Callable[[str], None]) -> None:
8922
+ super().__init__()
8923
+ self._fn = fn
9057
8924
 
8925
+ def emit(self, record):
8926
+ msg = self.format(record)
8927
+ self._fn(msg)
9058
8928
 
9059
- ########################################
9060
- # ../deploy/venvs.py
9061
- """
9062
- TODO:
9063
- - interp
9064
- - share more code with pyproject?
9065
- """
9066
8929
 
8930
+ ##
9067
8931
 
9068
- class DeployVenvManager:
8932
+
8933
+ class _RemoteExecutionMain:
9069
8934
  def __init__(
9070
8935
  self,
9071
- *,
9072
- atomics: AtomicPathSwapping,
8936
+ chan: RemoteChannel,
9073
8937
  ) -> None:
9074
8938
  super().__init__()
9075
8939
 
9076
- self._atomics = atomics
9077
-
9078
- async def setup_venv(
9079
- self,
9080
- spec: DeployVenvSpec,
9081
- git_dir: str,
9082
- venv_dir: str,
9083
- ) -> None:
9084
- sys_exe = 'python3'
9085
-
9086
- # !! NOTE: (most) venvs cannot be relocated, so an atomic swap can't be used. it's up to the path manager to
9087
- # garbage collect orphaned dirs.
9088
- await asyncio_subprocesses.check_call(sys_exe, '-m', 'venv', venv_dir)
9089
-
9090
- #
9091
-
9092
- venv_exe = os.path.join(venv_dir, 'bin', 'python3')
9093
-
9094
- #
9095
-
9096
- reqs_txt = os.path.join(git_dir, 'requirements.txt')
9097
-
9098
- if os.path.isfile(reqs_txt):
9099
- if spec.use_uv:
9100
- await asyncio_subprocesses.check_call(venv_exe, '-m', 'pip', 'install', 'uv')
9101
- pip_cmd = ['-m', 'uv', 'pip']
9102
- else:
9103
- pip_cmd = ['-m', 'pip']
9104
-
9105
- await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
9106
-
9107
-
9108
- ########################################
9109
- # ../remote/_main.py
9110
-
9111
-
9112
- ##
9113
-
9114
-
9115
- class _RemoteExecutionLogHandler(logging.Handler):
9116
- def __init__(self, fn: ta.Callable[[str], None]) -> None:
9117
- super().__init__()
9118
- self._fn = fn
9119
-
9120
- def emit(self, record):
9121
- msg = self.format(record)
9122
- self._fn(msg)
9123
-
9124
-
9125
- ##
9126
-
9127
-
9128
- class _RemoteExecutionMain:
9129
- def __init__(
9130
- self,
9131
- chan: RemoteChannel,
9132
- ) -> None:
9133
- super().__init__()
9134
-
9135
- self._chan = chan
8940
+ self._chan = chan
9136
8941
 
9137
8942
  self.__bootstrap: ta.Optional[MainBootstrap] = None
9138
8943
  self.__injector: ta.Optional[Injector] = None
@@ -9638,182 +9443,187 @@ def bind_commands(
9638
9443
 
9639
9444
 
9640
9445
  ########################################
9641
- # ../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
9642
9453
 
9454
+ github.com/wrmsr/omlish@rev
9455
+ """
9643
9456
 
9644
- class DeployAppManager(DeployPathOwner):
9457
+
9458
+ ##
9459
+
9460
+
9461
+ class DeployGitManager(SingleDirDeployPathOwner):
9645
9462
  def __init__(
9646
9463
  self,
9647
9464
  *,
9648
9465
  deploy_home: ta.Optional[DeployHome] = None,
9649
-
9650
- conf: DeployConfManager,
9651
- git: DeployGitManager,
9652
- venvs: DeployVenvManager,
9466
+ atomics: AtomicPathSwapping,
9653
9467
  ) -> None:
9654
- super().__init__()
9655
-
9656
- self._deploy_home = deploy_home
9657
-
9658
- self._conf = conf
9659
- self._git = git
9660
- self._venvs = venvs
9468
+ super().__init__(
9469
+ owned_dir='git',
9470
+ deploy_home=deploy_home,
9471
+ )
9661
9472
 
9662
- #
9473
+ self._atomics = atomics
9663
9474
 
9664
- _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
9665
- _APP_DIR = DeployPath.parse(_APP_DIR_STR)
9475
+ self._repo_dirs: ta.Dict[DeployGitRepo, DeployGitManager.RepoDir] = {}
9666
9476
 
9667
- _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
9668
- _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__()
9669
9484
 
9670
- _APP_DEPLOY_LINK = DeployPath.parse(f'{_DEPLOY_DIR_STR}apps/@app')
9671
- _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
+ )
9672
9492
 
9673
- @cached_nullary
9674
- def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
9675
- return {
9676
- self._APP_DIR,
9493
+ @property
9494
+ def repo(self) -> DeployGitRepo:
9495
+ return self._repo
9677
9496
 
9678
- 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}'
9679
9503
 
9680
- self._APP_DEPLOY_LINK,
9681
- self._CONF_DEPLOY_DIR,
9504
+ #
9682
9505
 
9683
- *[
9684
- DeployPath.parse(f'{self._APP_DIR_STR}{sfx}/')
9685
- for sfx in [
9686
- 'conf',
9687
- 'git',
9688
- 'venv',
9689
- ]
9690
- ],
9691
- }
9506
+ async def _call(self, *cmd: str) -> None:
9507
+ await asyncio_subprocesses.check_call(
9508
+ *cmd,
9509
+ cwd=self._dir,
9510
+ )
9692
9511
 
9693
- #
9512
+ #
9694
9513
 
9695
- async def prepare_app(
9696
- self,
9697
- spec: DeployAppSpec,
9698
- tags: DeployTagMap,
9699
- ) -> None:
9700
- 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
9701
9519
 
9702
- def build_path(pth: DeployPath) -> str:
9703
- 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)
9704
9522
 
9705
- app_dir = build_path(self._APP_DIR)
9706
- deploy_dir = build_path(self._DEPLOY_DIR)
9707
- 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)
9708
9526
 
9709
9527
  #
9710
9528
 
9711
- os.makedirs(deploy_dir, exist_ok=True)
9712
-
9713
- deploying_link = os.path.join(deploy_home, 'deploys/deploying')
9714
- relative_symlink(
9715
- deploy_dir,
9716
- deploying_link,
9717
- target_is_directory=True,
9718
- make_dirs=True,
9719
- )
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)
9720
9538
 
9721
- #
9539
+ dst_call = functools.partial(asyncio_subprocesses.check_call, cwd=dst_swap.tmp_path)
9540
+ await dst_call('git', 'init')
9722
9541
 
9723
- os.makedirs(app_dir)
9724
- relative_symlink(
9725
- app_dir,
9726
- app_deploy_link,
9727
- target_is_directory=True,
9728
- make_dirs=True,
9729
- )
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 []))
9730
9545
 
9731
- #
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
9732
9552
 
9733
- deploy_conf_dir = os.path.join(deploy_dir, 'conf')
9734
- 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)
9735
9559
 
9736
- #
9737
9560
 
9738
- # def mirror_symlinks(src: str, dst: str) -> None:
9739
- # def mirror_link(lp: str) -> None:
9740
- # check.state(os.path.islink(lp))
9741
- # shutil.copy2(
9742
- # lp,
9743
- # os.path.join(dst, os.path.relpath(lp, src)),
9744
- # follow_symlinks=False,
9745
- # )
9746
- #
9747
- # for dp, dns, fns in os.walk(src, followlinks=False):
9748
- # for fn in fns:
9749
- # mirror_link(os.path.join(dp, fn))
9750
- #
9751
- # for dn in dns:
9752
- # dp2 = os.path.join(dp, dn)
9753
- # if os.path.islink(dp2):
9754
- # mirror_link(dp2)
9755
- # else:
9756
- # os.makedirs(os.path.join(dst, os.path.relpath(dp2, src)))
9561
+ ########################################
9562
+ # ../deploy/paths/manager.py
9757
9563
 
9758
- current_link = os.path.join(deploy_home, 'deploys/current')
9759
9564
 
9760
- # if os.path.exists(current_link):
9761
- # mirror_symlinks(
9762
- # os.path.join(current_link, 'conf'),
9763
- # conf_tag_dir,
9764
- # )
9765
- # mirror_symlinks(
9766
- # os.path.join(current_link, 'apps'),
9767
- # os.path.join(deploy_dir, 'apps'),
9768
- # )
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__()
9769
9573
 
9770
- #
9574
+ self._deploy_home = deploy_home
9575
+ self._deploy_path_owners = deploy_path_owners
9771
9576
 
9772
- app_git_dir = os.path.join(app_dir, 'git')
9773
- await self._git.checkout(
9774
- spec.git,
9775
- app_git_dir,
9776
- )
9777
-
9778
- #
9779
-
9780
- if spec.venv is not None:
9781
- app_venv_dir = os.path.join(app_dir, 'venv')
9782
- await self._venvs.setup_venv(
9783
- spec.venv,
9784
- app_git_dir,
9785
- app_venv_dir,
9786
- )
9787
-
9788
- #
9789
-
9790
- if spec.conf is not None:
9791
- app_conf_dir = os.path.join(app_dir, 'conf')
9792
- await self._conf.write_app_conf(
9793
- spec.conf,
9794
- tags,
9795
- app_conf_dir,
9796
- deploy_conf_dir,
9797
- )
9798
-
9799
- #
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
9800
9586
 
9801
- os.replace(deploying_link, current_link)
9587
+ def validate_deploy_paths(self) -> None:
9588
+ self.owners_by_path()
9802
9589
 
9803
9590
 
9804
9591
  ########################################
9805
- # ../deploy/paths/inject.py
9592
+ # ../deploy/tmp.py
9806
9593
 
9807
9594
 
9808
- def bind_deploy_paths() -> InjectorBindings:
9809
- lst: ta.List[InjectorBindingOrBindings] = [
9810
- inj.bind_array(DeployPathOwner),
9811
- 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
+ )
9812
9608
 
9813
- inj.bind(DeployPathsManager, singleton=True),
9814
- ]
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
+ )
9815
9615
 
9816
- 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
+ )
9817
9627
 
9818
9628
 
9819
9629
  ########################################
@@ -10531,90 +10341,358 @@ class SystemInterpProvider(InterpProvider):
10531
10341
 
10532
10342
 
10533
10343
  ########################################
10534
- # ../deploy/deploy.py
10344
+ # ../deploy/paths/inject.py
10535
10345
 
10536
10346
 
10537
- 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),
10538
10351
 
10352
+ inj.bind(DeployPathsManager, singleton=True),
10353
+ ]
10539
10354
 
10540
- DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10355
+ return inj.as_bindings(*lst)
10541
10356
 
10542
10357
 
10543
- class DeployManager:
10544
- def __init__(
10545
- self,
10546
- *,
10547
- apps: DeployAppManager,
10548
- paths: DeployPathsManager,
10358
+ ########################################
10359
+ # ../remote/inject.py
10549
10360
 
10550
- utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10551
- ):
10552
- super().__init__()
10553
10361
 
10554
- self._apps = apps
10555
- self._paths = paths
10362
+ def bind_remote(
10363
+ *,
10364
+ remote_config: RemoteConfig,
10365
+ ) -> InjectorBindings:
10366
+ lst: ta.List[InjectorBindingOrBindings] = [
10367
+ inj.bind(remote_config),
10556
10368
 
10557
- self._utc_clock = utc_clock
10369
+ inj.bind(SubprocessRemoteSpawning, singleton=True),
10370
+ inj.bind(RemoteSpawning, to_key=SubprocessRemoteSpawning),
10558
10371
 
10559
- def _utc_now(self) -> datetime.datetime:
10560
- if self._utc_clock is not None:
10561
- 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
10562
10450
  else:
10563
- return datetime.datetime.now(tz=datetime.timezone.utc) # noqa
10451
+ return ['sh', '-c', get_best_python_sh(), '--']
10564
10452
 
10565
- def _make_deploy_time(self) -> DeployTime:
10566
- return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10567
10453
 
10568
- 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__(
10569
10586
  self,
10570
- spec: DeploySpec,
10587
+ providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10571
10588
  ) -> None:
10572
- self._paths.validate_deploy_paths()
10589
+ super().__init__()
10573
10590
 
10574
- #
10591
+ self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10575
10592
 
10576
- deploy_tags = DeployTagMap(
10577
- self._make_deploy_time(),
10578
- 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,
10579
10628
  )
10629
+ if not sv:
10630
+ return None
10580
10631
 
10581
- #
10632
+ bv = sv[-1]
10633
+ return await tp.install_version(bv)
10582
10634
 
10583
- for app in spec.apps:
10584
- app_tags = deploy_tags.add(
10585
- app.app,
10586
- app.key(),
10587
- DeployAppRev(app.git.rev),
10588
- )
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}')
10589
10647
 
10590
- await self._apps.prepare_app(
10591
- app,
10592
- app_tags,
10593
- )
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
+ ]])
10594
10671
 
10595
10672
 
10596
10673
  ########################################
10597
- # ../remote/inject.py
10674
+ # ../targets/inject.py
10598
10675
 
10599
10676
 
10600
- def bind_remote(
10601
- *,
10602
- remote_config: RemoteConfig,
10603
- ) -> InjectorBindings:
10677
+ def bind_targets() -> InjectorBindings:
10604
10678
  lst: ta.List[InjectorBindingOrBindings] = [
10605
- inj.bind(remote_config),
10606
-
10607
- inj.bind(SubprocessRemoteSpawning, singleton=True),
10608
- inj.bind(RemoteSpawning, to_key=SubprocessRemoteSpawning),
10679
+ inj.bind(LocalManageTargetConnector, singleton=True),
10680
+ inj.bind(DockerManageTargetConnector, singleton=True),
10681
+ inj.bind(SshManageTargetConnector, singleton=True),
10609
10682
 
10610
- inj.bind(PyremoteRemoteExecutionConnector, singleton=True),
10611
- inj.bind(InProcessRemoteExecutionConnector, singleton=True),
10683
+ inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10684
+ inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10612
10685
  ]
10613
10686
 
10614
10687
  #
10615
10688
 
10616
- if (pf := remote_config.payload_file) is not None:
10617
- 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))
10618
10696
 
10619
10697
  #
10620
10698
 
@@ -10622,290 +10700,318 @@ def bind_remote(
10622
10700
 
10623
10701
 
10624
10702
  ########################################
10625
- # ../system/inject.py
10703
+ # ../deploy/interp.py
10626
10704
 
10627
10705
 
10628
- def bind_system(
10629
- *,
10630
- system_config: SystemConfig,
10631
- ) -> InjectorBindings:
10632
- lst: ta.List[InjectorBindingOrBindings] = [
10633
- inj.bind(system_config),
10634
- ]
10706
+ ##
10635
10707
 
10636
- #
10637
10708
 
10638
- platform = system_config.platform or detect_system_platform()
10639
- 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
10640
10713
 
10641
- #
10714
+ @dc.dataclass(frozen=True)
10715
+ class Output(Command.Output):
10716
+ exe: str
10717
+ version: str
10718
+ opts: InterpOpts
10642
10719
 
10643
- if isinstance(platform, AmazonLinuxPlatform):
10644
- lst.extend([
10645
- inj.bind(YumSystemPackageManager, singleton=True),
10646
- inj.bind(SystemPackageManager, to_key=YumSystemPackageManager),
10647
- ])
10648
10720
 
10649
- elif isinstance(platform, LinuxPlatform):
10650
- lst.extend([
10651
- inj.bind(AptSystemPackageManager, singleton=True),
10652
- inj.bind(SystemPackageManager, to_key=AptSystemPackageManager),
10653
- ])
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
+ )
10654
10730
 
10655
- elif isinstance(platform, DarwinPlatform):
10656
- lst.extend([
10657
- inj.bind(BrewSystemPackageManager, singleton=True),
10658
- inj.bind(SystemPackageManager, to_key=BrewSystemPackageManager),
10659
- ])
10660
10731
 
10661
- #
10732
+ ########################################
10733
+ # ../deploy/venvs.py
10734
+ """
10735
+ TODO:
10736
+ - interp
10737
+ - share more code with pyproject?
10738
+ """
10662
10739
 
10663
- lst.extend([
10664
- bind_command(CheckSystemPackageCommand, CheckSystemPackageCommandExecutor),
10665
- ])
10666
10740
 
10667
- #
10741
+ class DeployVenvManager:
10742
+ def __init__(
10743
+ self,
10744
+ *,
10745
+ atomics: AtomicPathSwapping,
10746
+ ) -> None:
10747
+ super().__init__()
10668
10748
 
10669
- return inj.as_bindings(*lst)
10749
+ self._atomics = atomics
10670
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'
10671
10763
 
10672
- ########################################
10673
- # ../targets/connection.py
10764
+ #
10674
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)
10675
10769
 
10676
- ##
10770
+ #
10677
10771
 
10772
+ venv_exe = os.path.join(venv_dir, 'bin', 'python3')
10678
10773
 
10679
- class ManageTargetConnector(abc.ABC):
10680
- @abc.abstractmethod
10681
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10682
- raise NotImplementedError
10774
+ #
10683
10775
 
10684
- def _default_python(self, python: ta.Optional[ta.Sequence[str]]) -> ta.Sequence[str]:
10685
- check.not_isinstance(python, str)
10686
- if python is not None:
10687
- return python
10688
- else:
10689
- return ['sh', '-c', get_best_python_sh(), '--']
10776
+ reqs_txt = os.path.join(git_dir, 'requirements.txt')
10690
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']
10691
10784
 
10692
- ##
10785
+ await asyncio_subprocesses.check_call(venv_exe, *pip_cmd,'install', '-r', reqs_txt)
10693
10786
 
10694
10787
 
10695
- ManageTargetConnectorMap = ta.NewType('ManageTargetConnectorMap', ta.Mapping[ta.Type[ManageTarget], ManageTargetConnector]) # noqa
10788
+ ########################################
10789
+ # ../deploy/apps.py
10696
10790
 
10697
10791
 
10698
- @dc.dataclass(frozen=True)
10699
- class TypeSwitchedManageTargetConnector(ManageTargetConnector):
10700
- connectors: ManageTargetConnectorMap
10792
+ class DeployAppManager(DeployPathOwner):
10793
+ def __init__(
10794
+ self,
10795
+ *,
10796
+ deploy_home: ta.Optional[DeployHome] = None,
10701
10797
 
10702
- def get_connector(self, ty: ta.Type[ManageTarget]) -> ManageTargetConnector:
10703
- for k, v in self.connectors.items():
10704
- if issubclass(ty, k):
10705
- return v
10706
- raise KeyError(ty)
10798
+ conf: DeployConfManager,
10799
+ git: DeployGitManager,
10800
+ venvs: DeployVenvManager,
10801
+ ) -> None:
10802
+ super().__init__()
10707
10803
 
10708
- def connect(self, tgt: ManageTarget) -> ta.AsyncContextManager[CommandExecutor]:
10709
- return self.get_connector(type(tgt)).connect(tgt)
10804
+ self._deploy_home = deploy_home
10710
10805
 
10806
+ self._conf = conf
10807
+ self._git = git
10808
+ self._venvs = venvs
10711
10809
 
10712
- ##
10810
+ #
10713
10811
 
10812
+ _APP_DIR_STR = 'apps/@app/@time--@app-rev--@app-key/'
10813
+ _APP_DIR = DeployPath.parse(_APP_DIR_STR)
10714
10814
 
10715
- @dc.dataclass(frozen=True)
10716
- class LocalManageTargetConnector(ManageTargetConnector):
10717
- _local_executor: LocalCommandExecutor
10718
- _in_process_connector: InProcessRemoteExecutionConnector
10719
- _pyremote_connector: PyremoteRemoteExecutionConnector
10720
- _bootstrap: MainBootstrap
10815
+ _DEPLOY_DIR_STR = 'deploys/@time--@deploy-key/'
10816
+ _DEPLOY_DIR = DeployPath.parse(_DEPLOY_DIR_STR)
10721
10817
 
10722
- @contextlib.asynccontextmanager
10723
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10724
- 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/')
10725
10820
 
10726
- if isinstance(lmt, InProcessManageTarget):
10727
- imt = check.isinstance(lmt, InProcessManageTarget)
10821
+ @cached_nullary
10822
+ def get_owned_deploy_paths(self) -> ta.AbstractSet[DeployPath]:
10823
+ return {
10824
+ self._APP_DIR,
10728
10825
 
10729
- if imt.mode == InProcessManageTarget.Mode.DIRECT:
10730
- yield self._local_executor
10826
+ self._DEPLOY_DIR,
10731
10827
 
10732
- elif imt.mode == InProcessManageTarget.Mode.FAKE_REMOTE:
10733
- async with self._in_process_connector.connect() as rce:
10734
- yield rce
10828
+ self._APP_DEPLOY_LINK,
10829
+ self._CONF_DEPLOY_DIR,
10735
10830
 
10736
- else:
10737
- 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
+ }
10738
10840
 
10739
- elif isinstance(lmt, SubprocessManageTarget):
10740
- async with self._pyremote_connector.connect(
10741
- RemoteSpawning.Target(
10742
- python=self._default_python(lmt.python),
10743
- ),
10744
- self._bootstrap,
10745
- ) as rce:
10746
- yield rce
10841
+ #
10747
10842
 
10748
- else:
10749
- 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)
10750
10849
 
10850
+ def build_path(pth: DeployPath) -> str:
10851
+ return os.path.join(deploy_home, pth.render(tags))
10751
10852
 
10752
- ##
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)
10753
10856
 
10857
+ #
10754
10858
 
10755
- @dc.dataclass(frozen=True)
10756
- class DockerManageTargetConnector(ManageTargetConnector):
10757
- _pyremote_connector: PyremoteRemoteExecutionConnector
10758
- _bootstrap: MainBootstrap
10859
+ os.makedirs(deploy_dir, exist_ok=True)
10759
10860
 
10760
- @contextlib.asynccontextmanager
10761
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10762
- 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
+ )
10763
10870
 
10764
- sh_parts: ta.List[str] = ['docker']
10765
- if dmt.image is not None:
10766
- sh_parts.extend(['run', '-i', dmt.image])
10767
- elif dmt.container_id is not None:
10768
- sh_parts.extend(['exec', '-i', dmt.container_id])
10769
- else:
10770
- raise ValueError(dmt)
10871
+ #
10771
10872
 
10772
- async with self._pyremote_connector.connect(
10773
- RemoteSpawning.Target(
10774
- shell=' '.join(sh_parts),
10775
- python=self._default_python(dmt.python),
10776
- ),
10777
- self._bootstrap,
10778
- ) as rce:
10779
- 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)))
10780
10907
 
10908
+ current_link = os.path.join(deploy_home, 'deploys/current')
10781
10909
 
10782
- ##
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
+ # )
10783
10919
 
10920
+ #
10784
10921
 
10785
- @dc.dataclass(frozen=True)
10786
- class SshManageTargetConnector(ManageTargetConnector):
10787
- _pyremote_connector: PyremoteRemoteExecutionConnector
10788
- _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
+ )
10789
10927
 
10790
- @contextlib.asynccontextmanager
10791
- async def connect(self, tgt: ManageTarget) -> ta.AsyncGenerator[CommandExecutor, None]:
10792
- smt = check.isinstance(tgt, SshManageTarget)
10928
+ #
10793
10929
 
10794
- sh_parts: ta.List[str] = ['ssh']
10795
- if smt.key_file is not None:
10796
- sh_parts.extend(['-i', smt.key_file])
10797
- addr = check.not_none(smt.host)
10798
- if smt.username is not None:
10799
- addr = f'{smt.username}@{addr}'
10800
- 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
+ )
10801
10937
 
10802
- async with self._pyremote_connector.connect(
10803
- RemoteSpawning.Target(
10804
- shell=' '.join(sh_parts),
10805
- shell_quote=True,
10806
- python=self._default_python(smt.python),
10807
- ),
10808
- self._bootstrap,
10809
- ) as rce:
10810
- yield rce
10938
+ #
10811
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
+ )
10812
10948
 
10813
- ########################################
10814
- # ../../../omdev/interp/resolvers.py
10949
+ #
10815
10950
 
10951
+ os.replace(deploying_link, current_link)
10816
10952
 
10817
- INTERP_PROVIDER_TYPES_BY_NAME: ta.Mapping[str, ta.Type[InterpProvider]] = {
10818
- cls.name: cls for cls in deep_subclasses(InterpProvider) if abc.ABC not in cls.__bases__ # type: ignore
10819
- }
10820
10953
 
10954
+ ########################################
10955
+ # ../deploy/deploy.py
10821
10956
 
10822
- class InterpResolver:
10823
- def __init__(
10824
- self,
10825
- providers: ta.Sequence[ta.Tuple[str, InterpProvider]],
10826
- ) -> None:
10827
- super().__init__()
10828
10957
 
10829
- self._providers: ta.Mapping[str, InterpProvider] = collections.OrderedDict(providers)
10958
+ DEPLOY_TAG_DATETIME_FMT = '%Y%m%dT%H%M%SZ'
10830
10959
 
10831
- async def _resolve_installed(self, spec: InterpSpecifier) -> ta.Optional[ta.Tuple[InterpProvider, InterpVersion]]:
10832
- lst = [
10833
- (i, si)
10834
- for i, p in enumerate(self._providers.values())
10835
- for si in await p.get_installed_versions(spec)
10836
- if spec.contains(si)
10837
- ]
10838
10960
 
10839
- slst = sorted(lst, key=lambda t: (-t[0], t[1].version))
10840
- if not slst:
10841
- return None
10961
+ DeployManagerUtcClock = ta.NewType('DeployManagerUtcClock', Func0[datetime.datetime])
10842
10962
 
10843
- bi, bv = slst[-1]
10844
- bp = list(self._providers.values())[bi]
10845
- return (bp, bv)
10846
10963
 
10847
- async def resolve(
10964
+ class DeployManager:
10965
+ def __init__(
10848
10966
  self,
10849
- spec: InterpSpecifier,
10850
10967
  *,
10851
- install: bool = False,
10852
- ) -> ta.Optional[Interp]:
10853
- tup = await self._resolve_installed(spec)
10854
- if tup is not None:
10855
- bp, bv = tup
10856
- return await bp.get_installed_version(bv)
10968
+ apps: DeployAppManager,
10969
+ paths: DeployPathsManager,
10857
10970
 
10858
- if not install:
10859
- return None
10971
+ utc_clock: ta.Optional[DeployManagerUtcClock] = None,
10972
+ ):
10973
+ super().__init__()
10860
10974
 
10861
- tp = list(self._providers.values())[0] # noqa
10975
+ self._apps = apps
10976
+ self._paths = paths
10862
10977
 
10863
- sv = sorted(
10864
- [s for s in await tp.get_installable_versions(spec) if s in spec],
10865
- key=lambda s: s.version,
10866
- )
10867
- if not sv:
10868
- return None
10978
+ self._utc_clock = utc_clock
10869
10979
 
10870
- bv = sv[-1]
10871
- 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
10872
10985
 
10873
- async def list(self, spec: InterpSpecifier) -> None:
10874
- print('installed:')
10875
- for n, p in self._providers.items():
10876
- lst = [
10877
- si
10878
- for si in await p.get_installed_versions(spec)
10879
- if spec.contains(si)
10880
- ]
10881
- if lst:
10882
- print(f' {n}')
10883
- for si in lst:
10884
- print(f' {si}')
10986
+ def _make_deploy_time(self) -> DeployTime:
10987
+ return DeployTime(self._utc_now().strftime(DEPLOY_TAG_DATETIME_FMT))
10885
10988
 
10886
- print()
10989
+ async def run_deploy(
10990
+ self,
10991
+ spec: DeploySpec,
10992
+ ) -> None:
10993
+ self._paths.validate_deploy_paths()
10887
10994
 
10888
- print('installable:')
10889
- for n, p in self._providers.items():
10890
- lst = [
10891
- si
10892
- for si in await p.get_installable_versions(spec)
10893
- if spec.contains(si)
10894
- ]
10895
- if lst:
10896
- print(f' {n}')
10897
- for si in lst:
10898
- print(f' {si}')
10995
+ #
10899
10996
 
10997
+ deploy_tags = DeployTagMap(
10998
+ self._make_deploy_time(),
10999
+ spec.key(),
11000
+ )
10900
11001
 
10901
- DEFAULT_INTERP_RESOLVER = InterpResolver([(p.name, p) for p in [
10902
- # pyenv is preferred to system interpreters as it tends to have more support for things like tkinter
10903
- PyenvInterpProvider(try_update=True),
11002
+ #
10904
11003
 
10905
- 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
+ )
10906
11010
 
10907
- SystemInterpProvider(),
10908
- ]])
11011
+ await self._apps.prepare_app(
11012
+ app,
11013
+ app_tags,
11014
+ )
10909
11015
 
10910
11016
 
10911
11017
  ########################################
@@ -10936,65 +11042,6 @@ class DeployCommandExecutor(CommandExecutor[DeployCommand, DeployCommand.Output]
10936
11042
  return DeployCommand.Output()
10937
11043
 
10938
11044
 
10939
- ########################################
10940
- # ../targets/inject.py
10941
-
10942
-
10943
- def bind_targets() -> InjectorBindings:
10944
- lst: ta.List[InjectorBindingOrBindings] = [
10945
- inj.bind(LocalManageTargetConnector, singleton=True),
10946
- inj.bind(DockerManageTargetConnector, singleton=True),
10947
- inj.bind(SshManageTargetConnector, singleton=True),
10948
-
10949
- inj.bind(TypeSwitchedManageTargetConnector, singleton=True),
10950
- inj.bind(ManageTargetConnector, to_key=TypeSwitchedManageTargetConnector),
10951
- ]
10952
-
10953
- #
10954
-
10955
- def provide_manage_target_connector_map(injector: Injector) -> ManageTargetConnectorMap:
10956
- return ManageTargetConnectorMap({
10957
- LocalManageTarget: injector[LocalManageTargetConnector],
10958
- DockerManageTarget: injector[DockerManageTargetConnector],
10959
- SshManageTarget: injector[SshManageTargetConnector],
10960
- })
10961
- lst.append(inj.bind(provide_manage_target_connector_map, singleton=True))
10962
-
10963
- #
10964
-
10965
- return inj.as_bindings(*lst)
10966
-
10967
-
10968
- ########################################
10969
- # ../deploy/interp.py
10970
-
10971
-
10972
- ##
10973
-
10974
-
10975
- @dc.dataclass(frozen=True)
10976
- class InterpCommand(Command['InterpCommand.Output']):
10977
- spec: str
10978
- install: bool = False
10979
-
10980
- @dc.dataclass(frozen=True)
10981
- class Output(Command.Output):
10982
- exe: str
10983
- version: str
10984
- opts: InterpOpts
10985
-
10986
-
10987
- class InterpCommandExecutor(CommandExecutor[InterpCommand, InterpCommand.Output]):
10988
- async def execute(self, cmd: InterpCommand) -> InterpCommand.Output:
10989
- i = InterpSpecifier.parse(check.not_none(cmd.spec))
10990
- o = check.not_none(await DEFAULT_INTERP_RESOLVER.resolve(i, install=cmd.install))
10991
- return InterpCommand.Output(
10992
- exe=o.exe,
10993
- version=str(o.version.version),
10994
- opts=o.version.opts,
10995
- )
10996
-
10997
-
10998
11045
  ########################################
10999
11046
  # ../deploy/inject.py
11000
11047
 
@@ -11143,6 +11190,8 @@ def main_bootstrap(bs: MainBootstrap) -> Injector:
11143
11190
 
11144
11191
  @dc.dataclass(frozen=True)
11145
11192
  class ManageConfig:
11193
+ deploy_home: ta.Optional[str] = None
11194
+
11146
11195
  targets: ta.Optional[ta.Mapping[str, ManageTarget]] = None
11147
11196
 
11148
11197
 
@@ -11177,7 +11226,8 @@ class MainCli(ArgparseCli):
11177
11226
  argparse_arg('--deploy-home'),
11178
11227
 
11179
11228
  argparse_arg('target'),
11180
- argparse_arg('command', nargs='+'),
11229
+ argparse_arg('-f', '--command-file', action='append'),
11230
+ argparse_arg('command', nargs='*'),
11181
11231
  )
11182
11232
  async def run(self) -> None:
11183
11233
  bs = MainBootstrap(
@@ -11188,7 +11238,7 @@ class MainCli(ArgparseCli):
11188
11238
  ),
11189
11239
 
11190
11240
  deploy_config=DeployConfig(
11191
- deploy_home=self.args.deploy_home,
11241
+ deploy_home=self.args.deploy_home or self.config().deploy_home,
11192
11242
  ),
11193
11243
 
11194
11244
  remote_config=RemoteConfig(
@@ -11223,13 +11273,19 @@ class MainCli(ArgparseCli):
11223
11273
  #
11224
11274
 
11225
11275
  cmds: ta.List[Command] = []
11276
+
11226
11277
  cmd: Command
11227
- for c in self.args.command:
11278
+
11279
+ for c in self.args.command or []:
11228
11280
  if not c.startswith('{'):
11229
11281
  c = json.dumps({c: {}})
11230
11282
  cmd = msh.unmarshal_obj(json.loads(c), Command)
11231
11283
  cmds.append(cmd)
11232
11284
 
11285
+ for cf in self.args.command_file or []:
11286
+ cmd = read_config_file(cf, Command, msh=msh)
11287
+ cmds.append(cmd)
11288
+
11233
11289
  #
11234
11290
 
11235
11291
  async with injector[ManageTargetConnector].connect(tgt) as ce: