omdev 0.0.0.dev223__py3-none-any.whl → 0.0.0.dev224__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
omdev/ci/cache.py CHANGED
@@ -1,13 +1,17 @@
1
1
  # ruff: noqa: UP006 UP007
2
2
  import abc
3
+ import asyncio
3
4
  import dataclasses as dc
5
+ import functools
4
6
  import os.path
5
7
  import shutil
6
8
  import typing as ta
9
+ import urllib.request
7
10
 
8
11
  from omlish.lite.cached import cached_nullary
9
12
  from omlish.lite.check import check
10
13
  from omlish.lite.logs import log
14
+ from omlish.os.temp import make_temp_file
11
15
 
12
16
  from .consts import CI_CACHE_VERSION
13
17
 
@@ -162,3 +166,107 @@ class DirectoryFileCache(FileCache):
162
166
  else:
163
167
  shutil.copyfile(file_path, cache_file_path)
164
168
  return cache_file_path
169
+
170
+
171
+ ##
172
+
173
+
174
+ class DataCache:
175
+ @dc.dataclass(frozen=True)
176
+ class Data(abc.ABC): # noqa
177
+ pass
178
+
179
+ @dc.dataclass(frozen=True)
180
+ class BytesData(Data):
181
+ data: bytes
182
+
183
+ @dc.dataclass(frozen=True)
184
+ class FileData(Data):
185
+ file_path: str
186
+
187
+ @dc.dataclass(frozen=True)
188
+ class UrlData(Data):
189
+ url: str
190
+
191
+ #
192
+
193
+ @abc.abstractmethod
194
+ def get_data(self, key: str) -> ta.Awaitable[ta.Optional[Data]]:
195
+ raise NotImplementedError
196
+
197
+ @abc.abstractmethod
198
+ def put_data(self, key: str, data: Data) -> ta.Awaitable[None]:
199
+ raise NotImplementedError
200
+
201
+
202
+ #
203
+
204
+
205
+ @functools.singledispatch
206
+ async def read_data_cache_data(data: DataCache.Data) -> bytes:
207
+ raise TypeError(data)
208
+
209
+
210
+ @read_data_cache_data.register
211
+ async def _(data: DataCache.BytesData) -> bytes:
212
+ return data.data
213
+
214
+
215
+ @read_data_cache_data.register
216
+ async def _(data: DataCache.FileData) -> bytes:
217
+ with open(data.file_path, 'rb') as f: # noqa
218
+ return f.read()
219
+
220
+
221
+ @read_data_cache_data.register
222
+ async def _(data: DataCache.UrlData) -> bytes:
223
+ def inner() -> bytes:
224
+ with urllib.request.urlopen(urllib.request.Request( # noqa
225
+ data.url,
226
+ )) as resp:
227
+ return resp.read()
228
+
229
+ return await asyncio.get_running_loop().run_in_executor(None, inner)
230
+
231
+
232
+ #
233
+
234
+
235
+ class FileCacheDataCache(DataCache):
236
+ def __init__(
237
+ self,
238
+ file_cache: FileCache,
239
+ ) -> None:
240
+ super().__init__()
241
+
242
+ self._file_cache = file_cache
243
+
244
+ async def get_data(self, key: str) -> ta.Optional[DataCache.Data]:
245
+ if (file_path := await self._file_cache.get_file(key)) is None:
246
+ return None
247
+
248
+ return DataCache.FileData(file_path)
249
+
250
+ async def put_data(self, key: str, data: DataCache.Data) -> None:
251
+ steal = False
252
+
253
+ if isinstance(data, DataCache.BytesData):
254
+ file_path = make_temp_file()
255
+ with open(file_path, 'wb') as f: # noqa
256
+ f.write(data.data)
257
+ steal = True
258
+
259
+ elif isinstance(data, DataCache.FileData):
260
+ file_path = data.file_path
261
+
262
+ elif isinstance(data, DataCache.UrlData):
263
+ raise NotImplementedError
264
+
265
+ else:
266
+ raise TypeError(data)
267
+
268
+ await self._file_cache.put_file(
269
+ key,
270
+ file_path,
271
+ steal=steal,
272
+ )
omdev/ci/ci.py CHANGED
@@ -7,6 +7,7 @@ from omlish.lite.cached import async_cached_nullary
7
7
  from omlish.lite.cached import cached_nullary
8
8
  from omlish.lite.check import check
9
9
  from omlish.lite.contextmanagers import AsyncExitStacked
10
+ from omlish.lite.timing import log_timing_context
10
11
  from omlish.os.temp import temp_file_context
11
12
 
12
13
  from .compose import DockerComposeRun
@@ -17,7 +18,6 @@ from .docker.imagepulling import DockerImagePulling
17
18
  from .docker.utils import build_docker_file_hash
18
19
  from .requirements import build_requirements_hash
19
20
  from .shell import ShellCmd
20
- from .utils import log_timing_context
21
21
 
22
22
 
23
23
  ##
@@ -0,0 +1,262 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import abc
3
+ import asyncio
4
+ import contextlib
5
+ import dataclasses as dc
6
+ import json
7
+ import os.path
8
+ import typing as ta
9
+
10
+ from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
11
+ from omlish.lite.check import check
12
+ from omlish.lite.json import json_dumps_compact
13
+ from omlish.lite.logs import log
14
+ from omlish.lite.marshal import marshal_obj
15
+ from omlish.lite.marshal import unmarshal_obj
16
+
17
+ from ...dataserver.routes import DataServerRoute
18
+ from ...dataserver.server import DataServer
19
+ from ...dataserver.targets import BytesDataServerTarget
20
+ from ...dataserver.targets import DataServerTarget
21
+ from ...dataserver.targets import FileDataServerTarget
22
+ from ...oci.building import build_oci_index_repository
23
+ from ...oci.data import get_single_leaf_oci_image_index
24
+ from ...oci.dataserver import build_oci_repository_data_server_routes
25
+ from ...oci.loading import read_oci_repository_root_index
26
+ from ...oci.pack.repositories import OciPackedRepositoryBuilder
27
+ from ...oci.repositories import OciRepository
28
+ from ..cache import DataCache
29
+ from ..cache import read_data_cache_data
30
+ from .cache import DockerCache
31
+ from .dataserver import DockerDataServer
32
+ from .repositories import DockerImageRepositoryOpener
33
+
34
+
35
+ ##
36
+
37
+
38
+ @dc.dataclass(frozen=True)
39
+ class CacheServedDockerImageManifest:
40
+ @dc.dataclass(frozen=True)
41
+ class Route:
42
+ paths: ta.Sequence[str]
43
+
44
+ content_type: str
45
+ content_length: int
46
+
47
+ @dc.dataclass(frozen=True)
48
+ class Target(abc.ABC): # noqa
49
+ pass
50
+
51
+ @dc.dataclass(frozen=True)
52
+ class BytesTarget(Target):
53
+ data: bytes
54
+
55
+ @dc.dataclass(frozen=True)
56
+ class CacheKeyTarget(Target):
57
+ key: str
58
+
59
+ target: Target
60
+
61
+ def __post_init__(self) -> None:
62
+ check.not_isinstance(self.paths, str)
63
+
64
+ routes: ta.Sequence[Route]
65
+
66
+
67
+ #
68
+
69
+
70
+ async def build_cache_served_docker_image_manifest(
71
+ data_server_routes: ta.Iterable[DataServerRoute],
72
+ make_file_cache_key: ta.Callable[[str], ta.Awaitable[str]],
73
+ ) -> CacheServedDockerImageManifest:
74
+ routes: ta.List[CacheServedDockerImageManifest.Route] = []
75
+
76
+ for data_server_route in data_server_routes:
77
+ content_length: int
78
+
79
+ data_server_target = data_server_route.target
80
+ target: CacheServedDockerImageManifest.Route.Target
81
+ if isinstance(data_server_target, BytesDataServerTarget):
82
+ bytes_data = check.isinstance(data_server_target.data, bytes)
83
+ content_length = len(bytes_data)
84
+ target = CacheServedDockerImageManifest.Route.BytesTarget(bytes_data)
85
+
86
+ elif isinstance(data_server_target, FileDataServerTarget):
87
+ file_path = check.non_empty_str(data_server_target.file_path)
88
+ content_length = os.path.getsize(file_path)
89
+ cache_key = await make_file_cache_key(file_path)
90
+ target = CacheServedDockerImageManifest.Route.CacheKeyTarget(cache_key)
91
+
92
+ else:
93
+ raise TypeError(data_server_target)
94
+
95
+ routes.append(CacheServedDockerImageManifest.Route(
96
+ paths=data_server_route.paths,
97
+
98
+ content_type=check.non_empty_str(data_server_target.content_type),
99
+ content_length=content_length,
100
+
101
+ target=target,
102
+ ))
103
+
104
+ return CacheServedDockerImageManifest(
105
+ routes=routes,
106
+ )
107
+
108
+
109
+ #
110
+
111
+
112
+ async def build_cache_served_docker_image_data_server_routes(
113
+ manifest: CacheServedDockerImageManifest,
114
+ make_cache_key_target: ta.Callable[..., ta.Awaitable[DataServerTarget]],
115
+ ) -> ta.List[DataServerRoute]:
116
+ routes: ta.List[DataServerRoute] = []
117
+
118
+ for manifest_route in manifest.routes:
119
+ manifest_target = manifest_route.target
120
+
121
+ target_kwargs: dict = dict(
122
+ content_type=manifest_route.content_type,
123
+ content_length=manifest_route.content_length,
124
+ )
125
+
126
+ target: DataServerTarget
127
+
128
+ if isinstance(manifest_target, CacheServedDockerImageManifest.Route.BytesTarget):
129
+ target = DataServerTarget.of(manifest_target.data, **target_kwargs)
130
+
131
+ elif isinstance(manifest_target, CacheServedDockerImageManifest.Route.CacheKeyTarget):
132
+ target = await make_cache_key_target(manifest_target.key, **target_kwargs)
133
+
134
+ else:
135
+ raise TypeError(manifest_target)
136
+
137
+ routes.append(DataServerRoute(
138
+ paths=manifest_route.paths,
139
+ target=target,
140
+ ))
141
+
142
+ return routes
143
+
144
+
145
+ ##
146
+
147
+
148
+ class CacheServedDockerCache(DockerCache):
149
+ @dc.dataclass(frozen=True)
150
+ class Config:
151
+ port: int = 5021
152
+
153
+ repack: bool = True
154
+
155
+ def __init__(
156
+ self,
157
+ *,
158
+ config: Config = Config(),
159
+
160
+ image_repo_opener: DockerImageRepositoryOpener,
161
+ data_cache: DataCache,
162
+ ) -> None:
163
+ super().__init__()
164
+
165
+ self._config = config
166
+
167
+ self._image_repo_opener = image_repo_opener
168
+ self._data_cache = data_cache
169
+
170
+ async def load_cache_docker_image(self, key: str) -> ta.Optional[str]:
171
+ if (manifest_data := await self._data_cache.get_data(key)) is None:
172
+ return None
173
+
174
+ manifest_bytes = await read_data_cache_data(manifest_data)
175
+
176
+ manifest: CacheServedDockerImageManifest = unmarshal_obj(
177
+ json.loads(manifest_bytes.decode('utf-8')),
178
+ CacheServedDockerImageManifest,
179
+ )
180
+
181
+ async def make_cache_key_target(target_cache_key: str, **target_kwargs: ta.Any) -> DataServerTarget: # noqa
182
+ # FIXME: url
183
+ cache_data = check.not_none(await self._data_cache.get_data(target_cache_key))
184
+ file_path = check.isinstance(cache_data, DataCache.FileData).file_path
185
+ return DataServerTarget.of(
186
+ file_path=file_path,
187
+ **target_kwargs,
188
+ )
189
+
190
+ data_server_routes = await build_cache_served_docker_image_data_server_routes(
191
+ manifest,
192
+ make_cache_key_target,
193
+ )
194
+
195
+ data_server = DataServer(DataServer.HandlerRoute.of_(*data_server_routes))
196
+
197
+ image_url = f'localhost:{self._config.port}/{key}'
198
+
199
+ async with DockerDataServer(
200
+ self._config.port,
201
+ data_server,
202
+ handler_log=log,
203
+ ) as dds:
204
+ dds_run_task = asyncio.create_task(dds.run())
205
+ try:
206
+ # FIXME: lol
207
+ await asyncio.sleep(3.)
208
+
209
+ await asyncio_subprocesses.check_call(
210
+ 'docker',
211
+ 'pull',
212
+ image_url,
213
+ )
214
+
215
+ finally:
216
+ dds.stop_event.set()
217
+ await dds_run_task
218
+
219
+ return image_url
220
+
221
+ async def save_cache_docker_image(self, key: str, image: str) -> None:
222
+ async with contextlib.AsyncExitStack() as es:
223
+ image_repo: OciRepository = await es.enter_async_context(
224
+ self._image_repo_opener.open_docker_image_repository(image),
225
+ )
226
+
227
+ root_image_index = read_oci_repository_root_index(image_repo)
228
+ image_index = get_single_leaf_oci_image_index(root_image_index)
229
+
230
+ if self._config.repack:
231
+ prb: OciPackedRepositoryBuilder = es.enter_context(OciPackedRepositoryBuilder(
232
+ image_repo,
233
+ ))
234
+ built_repo = await asyncio.get_running_loop().run_in_executor(None, prb.build)
235
+
236
+ else:
237
+ built_repo = build_oci_index_repository(image_index)
238
+
239
+ data_server_routes = build_oci_repository_data_server_routes(
240
+ key,
241
+ built_repo,
242
+ )
243
+
244
+ async def make_file_cache_key(file_path: str) -> str:
245
+ target_cache_key = f'{key}--{os.path.basename(file_path).split(".")[0]}'
246
+ await self._data_cache.put_data(
247
+ target_cache_key,
248
+ DataCache.FileData(file_path),
249
+ )
250
+ return target_cache_key
251
+
252
+ cache_served_manifest = await build_cache_served_docker_image_manifest(
253
+ data_server_routes,
254
+ make_file_cache_key,
255
+ )
256
+
257
+ manifest_data = json_dumps_compact(marshal_obj(cache_served_manifest)).encode('utf-8')
258
+
259
+ await self._data_cache.put_data(
260
+ key,
261
+ DataCache.BytesData(manifest_data),
262
+ )
@@ -0,0 +1,204 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import asyncio
3
+ import contextlib
4
+ import logging
5
+ import ssl
6
+ import sys
7
+ import threading
8
+ import typing as ta
9
+
10
+ from omlish.docker.portrelay import DockerPortRelay
11
+ from omlish.http.coro.simple import make_simple_http_server
12
+ from omlish.http.handlers import HttpHandler
13
+ from omlish.http.handlers import LoggingHttpHandler
14
+ from omlish.lite.cached import cached_nullary
15
+ from omlish.lite.check import check
16
+ from omlish.lite.contextmanagers import AsyncExitStacked
17
+ from omlish.secrets.tempssl import generate_temp_localhost_ssl_cert
18
+ from omlish.sockets.server.server import SocketServer
19
+
20
+ from ...dataserver.http import DataServerHttpHandler
21
+ from ...dataserver.server import DataServer
22
+
23
+
24
+ ##
25
+
26
+
27
+ @contextlib.asynccontextmanager
28
+ async def start_docker_port_relay(
29
+ docker_port: int,
30
+ host_port: int,
31
+ **kwargs: ta.Any,
32
+ ) -> ta.AsyncGenerator[None, None]:
33
+ proc = await asyncio.create_subprocess_exec(*DockerPortRelay(
34
+ docker_port,
35
+ host_port,
36
+ **kwargs,
37
+ ).run_cmd())
38
+
39
+ try:
40
+ yield
41
+
42
+ finally:
43
+ try:
44
+ proc.kill()
45
+ except ProcessLookupError:
46
+ pass
47
+ await proc.wait()
48
+
49
+
50
+ ##
51
+
52
+
53
+ class AsyncioManagedSimpleHttpServer(AsyncExitStacked):
54
+ def __init__(
55
+ self,
56
+ port: int,
57
+ handler: HttpHandler,
58
+ *,
59
+ temp_ssl: bool = False,
60
+ ) -> None:
61
+ super().__init__()
62
+
63
+ self._port = port
64
+ self._handler = handler
65
+
66
+ self._temp_ssl = temp_ssl
67
+
68
+ self._lock = threading.RLock()
69
+
70
+ self._loop: ta.Optional[asyncio.AbstractEventLoop] = None
71
+ self._thread: ta.Optional[threading.Thread] = None
72
+ self._thread_exit_event = asyncio.Event()
73
+ self._server: ta.Optional[SocketServer] = None
74
+
75
+ @cached_nullary
76
+ def _ssl_context(self) -> ta.Optional['ssl.SSLContext']:
77
+ if not self._temp_ssl:
78
+ return None
79
+
80
+ ssl_cert = generate_temp_localhost_ssl_cert().cert # FIXME: async blocking
81
+
82
+ ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
83
+ ssl_context.load_cert_chain(
84
+ keyfile=ssl_cert.key_file,
85
+ certfile=ssl_cert.cert_file,
86
+ )
87
+
88
+ return ssl_context
89
+
90
+ @contextlib.contextmanager
91
+ def _make_server(self) -> ta.Iterator[SocketServer]:
92
+ with make_simple_http_server(
93
+ self._port,
94
+ self._handler,
95
+ ssl_context=self._ssl_context(),
96
+ use_threads=True,
97
+ ) as server:
98
+ yield server
99
+
100
+ def _thread_main(self) -> None:
101
+ try:
102
+ check.none(self._server)
103
+ with self._make_server() as server:
104
+ self._server = server
105
+
106
+ server.run()
107
+
108
+ finally:
109
+ check.not_none(self._loop).call_soon_threadsafe(self._thread_exit_event.set)
110
+
111
+ def is_running(self) -> bool:
112
+ return self._server is not None
113
+
114
+ def shutdown(self) -> None:
115
+ if (server := self._server) is not None:
116
+ server.shutdown(block=False)
117
+
118
+ async def run(self) -> None:
119
+ with self._lock:
120
+ check.none(self._loop)
121
+ check.none(self._thread)
122
+ check.state(not self._thread_exit_event.is_set())
123
+
124
+ if self._temp_ssl:
125
+ # Hit the ExitStack from this thread
126
+ self._ssl_context()
127
+
128
+ self._loop = check.not_none(asyncio.get_running_loop())
129
+ self._thread = threading.Thread(
130
+ target=self._thread_main,
131
+ daemon=True,
132
+ )
133
+ self._thread.start()
134
+
135
+ await self._thread_exit_event.wait()
136
+
137
+
138
+ ##
139
+
140
+
141
+ class DockerDataServer(AsyncExitStacked):
142
+ def __init__(
143
+ self,
144
+ port: int,
145
+ data_server: DataServer,
146
+ *,
147
+ handler_log: ta.Optional[logging.Logger] = None,
148
+ stop_event: ta.Optional[asyncio.Event] = None,
149
+ ) -> None:
150
+ super().__init__()
151
+
152
+ self._port = port
153
+ self._data_server = data_server
154
+
155
+ self._handler_log = handler_log
156
+
157
+ if stop_event is None:
158
+ stop_event = asyncio.Event()
159
+ self._stop_event = stop_event
160
+
161
+ @property
162
+ def stop_event(self) -> asyncio.Event:
163
+ return self._stop_event
164
+
165
+ async def run(self) -> None:
166
+ relay_port: ta.Optional[int] = None
167
+ if sys.platform == 'darwin':
168
+ relay_port = self._port
169
+ server_port = self._port + 1
170
+ else:
171
+ server_port = self._port
172
+
173
+ #
174
+
175
+ handler: HttpHandler = DataServerHttpHandler(self._data_server)
176
+
177
+ if self._handler_log is not None:
178
+ handler = LoggingHttpHandler(
179
+ handler,
180
+ self._handler_log,
181
+ )
182
+
183
+ #
184
+
185
+ async with contextlib.AsyncExitStack() as es:
186
+ if relay_port is not None:
187
+ await es.enter_async_context(start_docker_port_relay( # noqa
188
+ relay_port,
189
+ server_port,
190
+ intermediate_port=server_port + 1,
191
+ ))
192
+
193
+ async with AsyncioManagedSimpleHttpServer(
194
+ server_port,
195
+ handler,
196
+ temp_ssl=True,
197
+ ) as server:
198
+ server_run_task = asyncio.create_task(server.run())
199
+ try:
200
+ await self._stop_event.wait()
201
+
202
+ finally:
203
+ server.shutdown()
204
+ await server_run_task
@@ -3,8 +3,9 @@ import abc
3
3
  import dataclasses as dc
4
4
  import typing as ta
5
5
 
6
+ from omlish.lite.timing import log_timing_context
7
+
6
8
  from ..cache import FileCache
7
- from ..utils import log_timing_context
8
9
  from .cache import DockerCache
9
10
  from .cmds import is_docker_image_present
10
11
  from .cmds import pull_docker_image
@@ -0,0 +1,72 @@
1
+ # ruff: noqa: UP006 UP007
2
+ import asyncio
3
+ import os.path
4
+ import shlex
5
+ import typing as ta
6
+
7
+ from omlish.asyncs.asyncio.subprocesses import asyncio_subprocesses
8
+ from omlish.lite.cached import async_cached_nullary
9
+ from omlish.lite.cached import cached_nullary
10
+ from omlish.lite.contextmanagers import ExitStacked
11
+ from omlish.logs.timing import log_timing_context
12
+ from omlish.os.temp import temp_dir_context
13
+
14
+ from ...oci.building import BuiltOciImageIndexRepository
15
+ from ...oci.pack.repositories import OciPackedRepositoryBuilder
16
+ from ...oci.repositories import DirectoryOciRepository
17
+
18
+
19
+ ##
20
+
21
+
22
+ class PackedDockerImageIndexRepositoryBuilder(ExitStacked):
23
+ def __init__(
24
+ self,
25
+ *,
26
+ image_id: str,
27
+
28
+ temp_dir: ta.Optional[str] = None,
29
+ ) -> None:
30
+ super().__init__()
31
+
32
+ self._image_id = image_id
33
+
34
+ self._given_temp_dir = temp_dir
35
+
36
+ @cached_nullary
37
+ def _temp_dir(self) -> str:
38
+ if (given := self._given_temp_dir) is not None:
39
+ return given
40
+ else:
41
+ return self._enter_context(temp_dir_context()) # noqa
42
+
43
+ #
44
+
45
+ @async_cached_nullary
46
+ async def _save_to_dir(self) -> str:
47
+ save_dir = os.path.join(self._temp_dir(), 'built-image')
48
+ os.mkdir(save_dir)
49
+
50
+ with log_timing_context(f'Saving docker image {self._image_id}'):
51
+ await asyncio_subprocesses.check_call(
52
+ ' | '.join([
53
+ f'docker save {shlex.quote(self._image_id)}',
54
+ f'tar x -C {shlex.quote(save_dir)}',
55
+ ]),
56
+ shell=True,
57
+ )
58
+
59
+ return save_dir
60
+
61
+ #
62
+
63
+ @async_cached_nullary
64
+ async def build(self) -> BuiltOciImageIndexRepository:
65
+ saved_dir = await self._save_to_dir()
66
+
67
+ with OciPackedRepositoryBuilder(
68
+ DirectoryOciRepository(saved_dir),
69
+
70
+ temp_dir=self._temp_dir(),
71
+ ) as prb:
72
+ return await asyncio.get_running_loop().run_in_executor(None, prb.build)