ominfra 0.0.0.dev175__py3-none-any.whl → 0.0.0.dev176__py3-none-any.whl

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