offline-docker-cli 2.2.2__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.
- offline_docker/__init__.py +3 -0
- offline_docker/__main__.py +4 -0
- offline_docker/actions.py +173 -0
- offline_docker/build.py +245 -0
- offline_docker/cli.py +486 -0
- offline_docker/config.py +692 -0
- offline_docker/configure.py +282 -0
- offline_docker/context.py +111 -0
- offline_docker/defaults.py +48 -0
- offline_docker/discover.py +139 -0
- offline_docker/docker_cmd.py +136 -0
- offline_docker/download_debs.py +142 -0
- offline_docker/download_images.py +26 -0
- offline_docker/download_npm.py +106 -0
- offline_docker/download_trivy.py +90 -0
- offline_docker/download_wheels.py +363 -0
- offline_docker/download_yarn.py +208 -0
- offline_docker/ensure_base.py +61 -0
- offline_docker/hooks.py +84 -0
- offline_docker/init_cmd.py +54 -0
- offline_docker/load_images.py +34 -0
- offline_docker/oc_discovery.py +206 -0
- offline_docker/resolve.py +306 -0
- offline_docker/scan.py +496 -0
- offline_docker/sources/__init__.py +18 -0
- offline_docker/sources/base.py +18 -0
- offline_docker/sources/filesystem.py +17 -0
- offline_docker/sources/nexus.py +61 -0
- offline_docker/sources/nexus_client.py +67 -0
- offline_docker/sources/proget.py +49 -0
- offline_docker/sources/proget_client.py +48 -0
- offline_docker/sources/upstream.py +36 -0
- offline_docker/status.py +247 -0
- offline_docker/sync_cache.py +125 -0
- offline_docker/trivy_scan.py +86 -0
- offline_docker/tui/__init__.py +19 -0
- offline_docker/tui/app.py +56 -0
- offline_docker/tui/runner.py +87 -0
- offline_docker/tui/screens.py +333 -0
- offline_docker/user_config.py +89 -0
- offline_docker/utils.py +73 -0
- offline_docker/validate.py +53 -0
- offline_docker/wheel_prune.py +141 -0
- offline_docker_cli-2.2.2.dist-info/METADATA +417 -0
- offline_docker_cli-2.2.2.dist-info/RECORD +49 -0
- offline_docker_cli-2.2.2.dist-info/WHEEL +5 -0
- offline_docker_cli-2.2.2.dist-info/entry_points.txt +2 -0
- offline_docker_cli-2.2.2.dist-info/licenses/LICENSE +21 -0
- offline_docker_cli-2.2.2.dist-info/top_level.txt +1 -0
offline_docker/scan.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Recursive project inventory for discover/init."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from offline_docker.config import DockerfilesConfig, ImagesConfig, ProjectConfig
|
|
10
|
+
from offline_docker.defaults import (
|
|
11
|
+
DEFAULT_CRITICAL_WHEELS,
|
|
12
|
+
DEFAULT_DEB_PACKAGES,
|
|
13
|
+
DEFAULT_DOCKER_IMAGES,
|
|
14
|
+
)
|
|
15
|
+
from offline_docker.utils import parse_apt_packages_from_dockerfiles
|
|
16
|
+
|
|
17
|
+
SKIP_DIR_NAMES = frozenset(
|
|
18
|
+
{
|
|
19
|
+
".git",
|
|
20
|
+
"node_modules",
|
|
21
|
+
".venv",
|
|
22
|
+
"venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"offline-cache",
|
|
25
|
+
".tox",
|
|
26
|
+
"dist",
|
|
27
|
+
"build",
|
|
28
|
+
".mypy_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
"coverage",
|
|
31
|
+
".idea",
|
|
32
|
+
".cursor",
|
|
33
|
+
".pytest_cache",
|
|
34
|
+
".eggs",
|
|
35
|
+
".nox",
|
|
36
|
+
"htmlcov",
|
|
37
|
+
".next",
|
|
38
|
+
".nuxt",
|
|
39
|
+
"vendor",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_FRONTEND_DIR_PRIORITY = ("frontend", "web", "ui", "client", "fe_mt_react")
|
|
44
|
+
_FRONTEND_LOCK_NAMES = ("package-lock.json", "yarn.lock", "pnpm-lock.yaml")
|
|
45
|
+
_FROM_RE = re.compile(
|
|
46
|
+
r"^\s*FROM\s+(?:--platform=\S+\s+)?(?P<img>[^\s]+)(?:\s+AS\s+\S+)?\s*$",
|
|
47
|
+
re.IGNORECASE | re.MULTILINE,
|
|
48
|
+
)
|
|
49
|
+
_VAR_FROM_RE = re.compile(r"^\$\{|^[A-Z_][A-Z0-9_]*$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ScanInventory:
|
|
54
|
+
compose_files: list[str] = field(default_factory=list)
|
|
55
|
+
dockerfiles: list[str] = field(default_factory=list)
|
|
56
|
+
python_files: list[str] = field(default_factory=list)
|
|
57
|
+
package_json_dirs: list[str] = field(default_factory=list)
|
|
58
|
+
frontend_lock_paths: list[str] = field(default_factory=list)
|
|
59
|
+
ambiguities: list[str] = field(default_factory=list)
|
|
60
|
+
used_default_debs: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rel(root: Path, path: Path) -> str:
|
|
64
|
+
return path.relative_to(root).as_posix()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _should_skip_dir(name: str) -> bool:
|
|
68
|
+
return name in SKIP_DIR_NAMES or name.startswith(".")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def recursive_scan(root: Path) -> ScanInventory:
|
|
72
|
+
"""Walk project tree and collect compose/Dockerfile/python/frontend paths."""
|
|
73
|
+
root = root.resolve()
|
|
74
|
+
inv = ScanInventory()
|
|
75
|
+
compose: list[str] = []
|
|
76
|
+
dockerfiles: list[str] = []
|
|
77
|
+
python_files: list[str] = []
|
|
78
|
+
package_json_dirs: list[str] = []
|
|
79
|
+
frontend_locks: list[str] = []
|
|
80
|
+
|
|
81
|
+
for dirpath, dirnames, filenames in os_walk_filtered(root):
|
|
82
|
+
base = Path(dirpath)
|
|
83
|
+
for name in filenames:
|
|
84
|
+
path = base / name
|
|
85
|
+
rel = _rel(root, path)
|
|
86
|
+
lower = name.lower()
|
|
87
|
+
|
|
88
|
+
if name.startswith("docker-compose") and lower.endswith((".yml", ".yaml")):
|
|
89
|
+
compose.append(rel)
|
|
90
|
+
elif name == "Dockerfile" or name.startswith("Dockerfile."):
|
|
91
|
+
dockerfiles.append(rel)
|
|
92
|
+
elif name in ("pyproject.toml", "uv.lock") or (
|
|
93
|
+
name.startswith("requirements") and name.endswith(".txt")
|
|
94
|
+
):
|
|
95
|
+
python_files.append(rel)
|
|
96
|
+
elif name == "package.json":
|
|
97
|
+
parent = _rel(root, base) if base != root else "."
|
|
98
|
+
package_json_dirs.append(parent)
|
|
99
|
+
elif name in _FRONTEND_LOCK_NAMES:
|
|
100
|
+
frontend_locks.append(rel)
|
|
101
|
+
|
|
102
|
+
inv.compose_files = sorted(set(compose))
|
|
103
|
+
inv.dockerfiles = sorted(set(dockerfiles))
|
|
104
|
+
inv.python_files = sorted(set(python_files))
|
|
105
|
+
inv.package_json_dirs = sorted(set(package_json_dirs))
|
|
106
|
+
inv.frontend_lock_paths = sorted(set(frontend_locks))
|
|
107
|
+
return inv
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def os_walk_filtered(root: Path):
|
|
111
|
+
"""os.walk-like generator that prunes SKIP_DIR_NAMES in-place."""
|
|
112
|
+
import os
|
|
113
|
+
|
|
114
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
115
|
+
dirnames[:] = sorted(d for d in dirnames if not _should_skip_dir(d))
|
|
116
|
+
yield dirpath, dirnames, filenames
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _path_depth(rel: str) -> int:
|
|
120
|
+
if rel in (".", ""):
|
|
121
|
+
return 0
|
|
122
|
+
return len(Path(rel).parts)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _pick_frontend(inv: ScanInventory) -> tuple[str, str]:
|
|
126
|
+
"""Return (frontend_dir, lock_file_name)."""
|
|
127
|
+
lock_by_dir: dict[str, str] = {}
|
|
128
|
+
for rel in inv.frontend_lock_paths:
|
|
129
|
+
p = Path(rel)
|
|
130
|
+
parent = "." if p.parent == Path(".") else p.parent.as_posix()
|
|
131
|
+
# Prefer package-lock over yarn/pnpm if multiple in same dir
|
|
132
|
+
existing = lock_by_dir.get(parent)
|
|
133
|
+
if existing is None or p.name == "package-lock.json":
|
|
134
|
+
lock_by_dir[parent] = p.name
|
|
135
|
+
|
|
136
|
+
candidates = list(lock_by_dir.keys()) or list(inv.package_json_dirs)
|
|
137
|
+
if not candidates:
|
|
138
|
+
return "frontend", "package-lock.json"
|
|
139
|
+
|
|
140
|
+
def score(d: str) -> tuple[int, int, str]:
|
|
141
|
+
name = Path(d).name if d != "." else d
|
|
142
|
+
try:
|
|
143
|
+
prio = _FRONTEND_DIR_PRIORITY.index(name)
|
|
144
|
+
except ValueError:
|
|
145
|
+
prio = len(_FRONTEND_DIR_PRIORITY)
|
|
146
|
+
return (prio, _path_depth(d), d)
|
|
147
|
+
|
|
148
|
+
chosen = min(candidates, key=score)
|
|
149
|
+
lock_name = lock_by_dir.get(chosen, "package-lock.json")
|
|
150
|
+
return chosen, lock_name
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_frontend_dockerfile(rel: str, frontend_dir: str) -> bool:
|
|
154
|
+
if frontend_dir != ".":
|
|
155
|
+
return rel == f"{frontend_dir}/Dockerfile" or rel.startswith(
|
|
156
|
+
f"{frontend_dir}/Dockerfile."
|
|
157
|
+
)
|
|
158
|
+
parts_lower = {p.lower() for p in Path(rel).parts[:-1]}
|
|
159
|
+
return bool(parts_lower & set(_FRONTEND_DIR_PRIORITY))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _classify_dockerfiles(inv: ScanInventory, frontend_dir: str) -> DockerfilesConfig:
|
|
163
|
+
df = DockerfilesConfig()
|
|
164
|
+
|
|
165
|
+
for rel in inv.dockerfiles:
|
|
166
|
+
name = Path(rel).name
|
|
167
|
+
parent = Path(rel).parent.as_posix()
|
|
168
|
+
offline = "offline" in name.lower()
|
|
169
|
+
|
|
170
|
+
if _is_frontend_dockerfile(rel, frontend_dir):
|
|
171
|
+
if offline:
|
|
172
|
+
df.frontend_offline = rel
|
|
173
|
+
else:
|
|
174
|
+
df.frontend = rel
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
if name.startswith("Dockerfile.base"):
|
|
178
|
+
if offline:
|
|
179
|
+
df.base_offline = rel
|
|
180
|
+
else:
|
|
181
|
+
df.base = rel
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
if name.startswith("Dockerfile") and offline:
|
|
185
|
+
if parent == "." or any(
|
|
186
|
+
x in parent.lower() for x in ("backend", "api", "server")
|
|
187
|
+
):
|
|
188
|
+
df.backend_offline = rel
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if name == "Dockerfile":
|
|
192
|
+
if parent == "." or any(
|
|
193
|
+
x in parent.lower() for x in ("backend", "api", "server")
|
|
194
|
+
):
|
|
195
|
+
df.backend = rel
|
|
196
|
+
elif "base" in parent.lower():
|
|
197
|
+
df.base = rel
|
|
198
|
+
|
|
199
|
+
# Prefer canonical root names when present
|
|
200
|
+
preferred = {
|
|
201
|
+
"base": "Dockerfile.base",
|
|
202
|
+
"base_offline": "Dockerfile.base.offline",
|
|
203
|
+
"backend": "Dockerfile",
|
|
204
|
+
"backend_offline": "Dockerfile.offline",
|
|
205
|
+
}
|
|
206
|
+
for attr, default_name in preferred.items():
|
|
207
|
+
if default_name in inv.dockerfiles:
|
|
208
|
+
setattr(df, attr, default_name)
|
|
209
|
+
|
|
210
|
+
if frontend_dir != ".":
|
|
211
|
+
fe_docker = f"{frontend_dir}/Dockerfile"
|
|
212
|
+
fe_offline = f"{frontend_dir}/Dockerfile.offline"
|
|
213
|
+
if fe_docker in inv.dockerfiles:
|
|
214
|
+
df.frontend = fe_docker
|
|
215
|
+
if fe_offline in inv.dockerfiles:
|
|
216
|
+
df.frontend_offline = fe_offline
|
|
217
|
+
|
|
218
|
+
return df
|
|
219
|
+
|
|
220
|
+
def _pick_compose(inv: ScanInventory) -> tuple[list[str], str, str]:
|
|
221
|
+
files = list(inv.compose_files)
|
|
222
|
+
# Prefer root-level compose in listing order (already sorted; root first by depth)
|
|
223
|
+
files_sorted = sorted(files, key=lambda f: (_path_depth(f), f))
|
|
224
|
+
|
|
225
|
+
compose_offline = "docker-compose.offline.yml"
|
|
226
|
+
compose_online = "docker-compose.yml"
|
|
227
|
+
|
|
228
|
+
offline_candidates = [f for f in files_sorted if "offline" in Path(f).name.lower()]
|
|
229
|
+
online_candidates = [
|
|
230
|
+
f
|
|
231
|
+
for f in files_sorted
|
|
232
|
+
if "offline" not in Path(f).name.lower() and "lab" not in Path(f).name.lower()
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
if offline_candidates:
|
|
236
|
+
compose_offline = offline_candidates[0]
|
|
237
|
+
if online_candidates:
|
|
238
|
+
compose_online = online_candidates[0]
|
|
239
|
+
elif files_sorted:
|
|
240
|
+
compose_online = files_sorted[0]
|
|
241
|
+
|
|
242
|
+
return files_sorted, compose_online, compose_offline
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _guess_app_name(root: Path, compose_files: list[str]) -> str:
|
|
246
|
+
for compose in compose_files:
|
|
247
|
+
path = root / compose
|
|
248
|
+
if not path.is_file():
|
|
249
|
+
continue
|
|
250
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
251
|
+
m = re.search(r"^name:\s*(\S+)", text, re.MULTILINE)
|
|
252
|
+
if m:
|
|
253
|
+
return m.group(1).strip('"').strip("'")
|
|
254
|
+
return root.name.replace("-", "").replace("_", "")[:20] or "app"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _guess_image_names(root: Path, compose_online: str) -> ImagesConfig:
|
|
258
|
+
images = ImagesConfig()
|
|
259
|
+
online_compose = root / compose_online
|
|
260
|
+
if online_compose.is_file():
|
|
261
|
+
text = online_compose.read_text(encoding="utf-8", errors="replace")
|
|
262
|
+
for svc in ("backend", "frontend"):
|
|
263
|
+
pattern = rf"(?:\w+_)?{svc.upper()}_IMAGE:-([^}}]+)\}}"
|
|
264
|
+
match = re.search(pattern, text, re.IGNORECASE)
|
|
265
|
+
if match:
|
|
266
|
+
setattr(images, svc, match.group(1).strip())
|
|
267
|
+
|
|
268
|
+
base_match = re.search(r"BASE_IMAGE:\s*\$\{{[^:}]+:-([^}]+)\}", text)
|
|
269
|
+
if base_match:
|
|
270
|
+
images.base = base_match.group(1).strip()
|
|
271
|
+
|
|
272
|
+
if images.backend == "app-backend:latest":
|
|
273
|
+
images.backend = f"{root.name}-backend:latest"
|
|
274
|
+
if images.frontend == "app-frontend:latest":
|
|
275
|
+
images.frontend = f"{root.name}-frontend:latest"
|
|
276
|
+
return images
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _pick_python_lock_files(inv: ScanInventory, dockerfiles: DockerfilesConfig) -> list[str]:
|
|
280
|
+
if not inv.python_files:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
def score(rel: str) -> tuple[int, int, int, str]:
|
|
284
|
+
depth = _path_depth(rel)
|
|
285
|
+
# Prefer next to backend dockerfile
|
|
286
|
+
backend_parent = Path(dockerfiles.backend).parent.as_posix()
|
|
287
|
+
near_backend = 0
|
|
288
|
+
if backend_parent != "." and rel.startswith(f"{backend_parent}/"):
|
|
289
|
+
near_backend = -1
|
|
290
|
+
elif depth == 0 or Path(rel).parent == Path("."):
|
|
291
|
+
near_backend = -2 # root wins
|
|
292
|
+
name_prio = {
|
|
293
|
+
"pyproject.toml": 0,
|
|
294
|
+
"uv.lock": 1,
|
|
295
|
+
"requirements-base.txt": 2,
|
|
296
|
+
"requirements.txt": 3,
|
|
297
|
+
}.get(Path(rel).name, 9)
|
|
298
|
+
return (near_backend, depth, name_prio, rel)
|
|
299
|
+
|
|
300
|
+
ordered = sorted(inv.python_files, key=score)
|
|
301
|
+
# Keep reasonable set: all root files + files in chosen project dir
|
|
302
|
+
root_files = [f for f in ordered if Path(f).parent == Path(".")]
|
|
303
|
+
if root_files:
|
|
304
|
+
return root_files
|
|
305
|
+
|
|
306
|
+
# Group by directory; take the shallowest directory's files
|
|
307
|
+
by_dir: dict[str, list[str]] = {}
|
|
308
|
+
for f in ordered:
|
|
309
|
+
parent = "." if Path(f).parent == Path(".") else Path(f).parent.as_posix()
|
|
310
|
+
by_dir.setdefault(parent, []).append(f)
|
|
311
|
+
best_dir = min(by_dir.keys(), key=lambda d: (_path_depth(d), d))
|
|
312
|
+
return by_dir[best_dir]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _detect_torch_file(root: Path, python_lock_files: list[str]) -> str:
|
|
316
|
+
for rel in python_lock_files:
|
|
317
|
+
if Path(rel).name.startswith("requirements") and (root / rel).is_file():
|
|
318
|
+
text = (root / rel).read_text(encoding="utf-8", errors="replace")
|
|
319
|
+
if "torch==" in text:
|
|
320
|
+
return rel
|
|
321
|
+
for rel in python_lock_files:
|
|
322
|
+
if Path(rel).name.startswith("requirements"):
|
|
323
|
+
return rel
|
|
324
|
+
return "requirements-base.txt"
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def parse_from_images(*dockerfiles: Path) -> list[str]:
|
|
328
|
+
"""Extract simple FROM image:tag references (skip variables / scratch)."""
|
|
329
|
+
images: list[str] = []
|
|
330
|
+
seen: set[str] = set()
|
|
331
|
+
for dockerfile in dockerfiles:
|
|
332
|
+
if not dockerfile.is_file():
|
|
333
|
+
continue
|
|
334
|
+
text = dockerfile.read_text(encoding="utf-8", errors="replace")
|
|
335
|
+
for m in _FROM_RE.finditer(text):
|
|
336
|
+
img = m.group("img").strip()
|
|
337
|
+
if not img or img.lower() == "scratch":
|
|
338
|
+
continue
|
|
339
|
+
if img.startswith("$") or _VAR_FROM_RE.match(img):
|
|
340
|
+
continue
|
|
341
|
+
if img not in seen:
|
|
342
|
+
seen.add(img)
|
|
343
|
+
images.append(img)
|
|
344
|
+
return images
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _collect_ambiguities(
|
|
348
|
+
inv: ScanInventory, frontend_dir: str, python_lock_files: list[str]
|
|
349
|
+
) -> list[str]:
|
|
350
|
+
notes: list[str] = []
|
|
351
|
+
lock_dirs = {
|
|
352
|
+
("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
|
|
353
|
+
for p in inv.frontend_lock_paths
|
|
354
|
+
}
|
|
355
|
+
pkg_dirs = set(inv.package_json_dirs)
|
|
356
|
+
fe_candidates = lock_dirs or pkg_dirs
|
|
357
|
+
if len(fe_candidates) > 1:
|
|
358
|
+
others = sorted(fe_candidates - {frontend_dir})
|
|
359
|
+
notes.append(
|
|
360
|
+
f"несколько frontend-кандидатов; выбран {frontend_dir}/ "
|
|
361
|
+
f"(ещё: {', '.join(others)})"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
py_dirs = {
|
|
365
|
+
("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
|
|
366
|
+
for p in inv.python_files
|
|
367
|
+
}
|
|
368
|
+
chosen_py_dirs = {
|
|
369
|
+
("." if Path(p).parent == Path(".") else Path(p).parent.as_posix())
|
|
370
|
+
for p in python_lock_files
|
|
371
|
+
}
|
|
372
|
+
if len(py_dirs) > 1:
|
|
373
|
+
others = sorted(py_dirs - chosen_py_dirs)
|
|
374
|
+
if others:
|
|
375
|
+
notes.append(
|
|
376
|
+
f"несколько python-деревьев; в конфиг: {', '.join(sorted(chosen_py_dirs)) or '(пусто)'} "
|
|
377
|
+
f"(ещё: {', '.join(others)})"
|
|
378
|
+
)
|
|
379
|
+
return notes
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def inventory_to_draft_config(root: Path, inventory: ScanInventory) -> ProjectConfig:
|
|
383
|
+
"""Map scan inventory to ProjectConfig using broader heuristics."""
|
|
384
|
+
root = root.resolve()
|
|
385
|
+
frontend_dir, frontend_lock = _pick_frontend(inventory)
|
|
386
|
+
dockerfiles = _classify_dockerfiles(inventory, frontend_dir)
|
|
387
|
+
compose_files, compose_online, compose_offline = _pick_compose(inventory)
|
|
388
|
+
python_lock_files = _pick_python_lock_files(inventory, dockerfiles)
|
|
389
|
+
inventory.ambiguities = _collect_ambiguities(
|
|
390
|
+
inventory, frontend_dir, python_lock_files
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
app_name = _guess_app_name(root, compose_files)
|
|
394
|
+
|
|
395
|
+
deb_from_docker = parse_apt_packages_from_dockerfiles(
|
|
396
|
+
root / dockerfiles.base,
|
|
397
|
+
root / dockerfiles.backend,
|
|
398
|
+
)
|
|
399
|
+
inventory.used_default_debs = not bool(deb_from_docker)
|
|
400
|
+
deb_packages = deb_from_docker if deb_from_docker else list(DEFAULT_DEB_PACKAGES)
|
|
401
|
+
|
|
402
|
+
from_imgs = parse_from_images(
|
|
403
|
+
*(root / rel for rel in inventory.dockerfiles)
|
|
404
|
+
)
|
|
405
|
+
docker_images: list[str] = []
|
|
406
|
+
seen_img: set[str] = set()
|
|
407
|
+
for img in list(DEFAULT_DOCKER_IMAGES) + from_imgs:
|
|
408
|
+
if img not in seen_img:
|
|
409
|
+
seen_img.add(img)
|
|
410
|
+
docker_images.append(img)
|
|
411
|
+
|
|
412
|
+
log_dir = root / "storage" / "logs"
|
|
413
|
+
if not log_dir.parent.is_dir():
|
|
414
|
+
log_dir = root / "logs"
|
|
415
|
+
|
|
416
|
+
cfg = ProjectConfig(
|
|
417
|
+
root=root,
|
|
418
|
+
offline_cache=root / "offline-cache",
|
|
419
|
+
log_dir=log_dir,
|
|
420
|
+
compose_files=compose_files,
|
|
421
|
+
compose_offline=compose_offline,
|
|
422
|
+
compose_online=compose_online,
|
|
423
|
+
compose_build=[compose_online, compose_offline],
|
|
424
|
+
compose_up_offline=[compose_online, compose_offline],
|
|
425
|
+
frontend_dir=frontend_dir,
|
|
426
|
+
frontend_lock=frontend_lock,
|
|
427
|
+
python_lock_files=python_lock_files
|
|
428
|
+
or ["pyproject.toml", "uv.lock", "requirements-base.txt"],
|
|
429
|
+
torch_version_file=_detect_torch_file(root, python_lock_files),
|
|
430
|
+
deb_packages=deb_packages,
|
|
431
|
+
docker_images=docker_images,
|
|
432
|
+
dockerfiles=dockerfiles,
|
|
433
|
+
images=_guess_image_names(root, compose_online),
|
|
434
|
+
critical_wheels=list(DEFAULT_CRITICAL_WHEELS),
|
|
435
|
+
app_name=app_name,
|
|
436
|
+
)
|
|
437
|
+
return cfg
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def compute_init_gaps(cfg: ProjectConfig, inventory: ScanInventory) -> list[str]:
|
|
441
|
+
"""Human-readable list of missing offline artifacts / ambiguities to fix."""
|
|
442
|
+
gaps: list[str] = []
|
|
443
|
+
root = cfg.root
|
|
444
|
+
|
|
445
|
+
def missing(rel: str, label: str) -> None:
|
|
446
|
+
if not (root / rel).is_file():
|
|
447
|
+
gaps.append(f"нет файла {label}: {rel}")
|
|
448
|
+
|
|
449
|
+
missing(cfg.dockerfiles.base_offline, "Dockerfile.base.offline")
|
|
450
|
+
missing(cfg.dockerfiles.backend_offline, "Dockerfile.offline (backend)")
|
|
451
|
+
missing(cfg.dockerfiles.frontend_offline, "frontend Dockerfile.offline")
|
|
452
|
+
|
|
453
|
+
offline_compose = [
|
|
454
|
+
f for f in inventory.compose_files if "offline" in Path(f).name.lower()
|
|
455
|
+
]
|
|
456
|
+
if not offline_compose:
|
|
457
|
+
gaps.append(
|
|
458
|
+
f"нет compose с offline в имени (ожидается например {cfg.compose_offline})"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if not (root / cfg.dockerfiles.base).is_file():
|
|
462
|
+
gaps.append(f"нет base Dockerfile: {cfg.dockerfiles.base}")
|
|
463
|
+
if not (root / cfg.dockerfiles.backend).is_file():
|
|
464
|
+
gaps.append(f"нет backend Dockerfile: {cfg.dockerfiles.backend}")
|
|
465
|
+
if not (root / cfg.dockerfiles.frontend).is_file():
|
|
466
|
+
gaps.append(f"нет frontend Dockerfile: {cfg.dockerfiles.frontend}")
|
|
467
|
+
|
|
468
|
+
fe_lock = root / cfg.frontend_dir / cfg.frontend_lock
|
|
469
|
+
if not fe_lock.is_file():
|
|
470
|
+
gaps.append(
|
|
471
|
+
f"не найден frontend lock: {cfg.frontend_dir}/{cfg.frontend_lock}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
existing_py = [f for f in cfg.python_lock_files if (root / f).is_file()]
|
|
475
|
+
if not existing_py and not inventory.python_files:
|
|
476
|
+
gaps.append("не найдены python lock/requirements (pyproject.toml, uv.lock, requirements*.txt)")
|
|
477
|
+
|
|
478
|
+
if inventory.used_default_debs:
|
|
479
|
+
gaps.append(
|
|
480
|
+
"APT-пакеты не извлечены из Dockerfile — в черновике DEFAULT_DEB_PACKAGES; "
|
|
481
|
+
"проверьте debs.packages"
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
for note in inventory.ambiguities:
|
|
485
|
+
gaps.append(f"ambiguity: {note}")
|
|
486
|
+
|
|
487
|
+
return gaps
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def resolve_init_output_path(root: Path) -> Path:
|
|
491
|
+
"""Canonical config if absent, else .offline-docker.generated.json."""
|
|
492
|
+
from offline_docker.config import find_project_config_file
|
|
493
|
+
|
|
494
|
+
if find_project_config_file(root) is not None:
|
|
495
|
+
return root / ".offline-docker.generated.json"
|
|
496
|
+
return root / ".offline-docker.json"
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Package / cache source backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from offline_docker.sources.base import Ecosystem, PackageSource
|
|
6
|
+
from offline_docker.sources.filesystem import FilesystemSource
|
|
7
|
+
from offline_docker.sources.nexus import NexusSource
|
|
8
|
+
from offline_docker.sources.proget import ProGetSource
|
|
9
|
+
from offline_docker.sources.upstream import UpstreamSource
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Ecosystem",
|
|
13
|
+
"FilesystemSource",
|
|
14
|
+
"NexusSource",
|
|
15
|
+
"PackageSource",
|
|
16
|
+
"ProGetSource",
|
|
17
|
+
"UpstreamSource",
|
|
18
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Package source protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
Ecosystem = Literal["pypi", "npm", "apt"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class PackageSource(Protocol):
|
|
12
|
+
name: str
|
|
13
|
+
source_type: str
|
|
14
|
+
enabled: bool
|
|
15
|
+
|
|
16
|
+
def index_urls(self, ecosystem: Ecosystem) -> list[str]:
|
|
17
|
+
"""Ordered index/registry URLs for the ecosystem (may be empty)."""
|
|
18
|
+
...
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Local offline-cache filesystem source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from offline_docker.sources.base import Ecosystem
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FilesystemSource:
|
|
9
|
+
source_type = "filesystem"
|
|
10
|
+
|
|
11
|
+
def __init__(self, name: str = "local", *, enabled: bool = True) -> None:
|
|
12
|
+
self.name = name
|
|
13
|
+
self.enabled = enabled
|
|
14
|
+
|
|
15
|
+
def index_urls(self, ecosystem: Ecosystem) -> list[str]:
|
|
16
|
+
# Local is consumed via --find-links / npm cache mount, not HTTP indexes.
|
|
17
|
+
return []
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Sonatype Nexus Repository source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
from offline_docker.sources.base import Ecosystem
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NexusSource:
|
|
11
|
+
source_type = "nexus"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
name: str = "nexus",
|
|
16
|
+
*,
|
|
17
|
+
url: str,
|
|
18
|
+
repos: dict[str, str] | None = None,
|
|
19
|
+
auth_env: str = "NEXUS_AUTH",
|
|
20
|
+
enabled: bool = True,
|
|
21
|
+
publish: bool = False,
|
|
22
|
+
raw_repo: str | None = None,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.name = name
|
|
25
|
+
self.url = url.rstrip("/")
|
|
26
|
+
self.repos = dict(repos or {})
|
|
27
|
+
self.auth_env = auth_env
|
|
28
|
+
self.enabled = enabled and bool(url.strip())
|
|
29
|
+
self.publish = publish
|
|
30
|
+
self.raw_repo = raw_repo or self.repos.get("raw")
|
|
31
|
+
|
|
32
|
+
def _repo_url(self, repo: str, suffix: str = "") -> str:
|
|
33
|
+
base = f"{self.url}/repository/{repo.strip('/')}/"
|
|
34
|
+
if not suffix:
|
|
35
|
+
return base
|
|
36
|
+
return urljoin(base, suffix.lstrip("/"))
|
|
37
|
+
|
|
38
|
+
def index_urls(self, ecosystem: Ecosystem) -> list[str]:
|
|
39
|
+
if not self.enabled:
|
|
40
|
+
return []
|
|
41
|
+
if ecosystem == "pypi":
|
|
42
|
+
repo = self.repos.get("pypi")
|
|
43
|
+
if not repo:
|
|
44
|
+
return []
|
|
45
|
+
return [self._repo_url(repo, "simple").rstrip("/") ]
|
|
46
|
+
if ecosystem == "npm":
|
|
47
|
+
repo = self.repos.get("npm")
|
|
48
|
+
if not repo:
|
|
49
|
+
return []
|
|
50
|
+
return [self._repo_url(repo)]
|
|
51
|
+
if ecosystem == "apt":
|
|
52
|
+
repo = self.repos.get("apt") or self.repos.get("deb")
|
|
53
|
+
if not repo:
|
|
54
|
+
return []
|
|
55
|
+
return [self._repo_url(repo)]
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
def raw_base_url(self) -> str | None:
|
|
59
|
+
if not self.enabled or not self.raw_repo:
|
|
60
|
+
return None
|
|
61
|
+
return self._repo_url(self.raw_repo)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""HTTP helpers for Nexus raw repository sync."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _auth_header(auth_env: str) -> dict[str, str]:
|
|
13
|
+
raw = os.environ.get(auth_env, "").strip()
|
|
14
|
+
if not raw:
|
|
15
|
+
return {}
|
|
16
|
+
if raw.lower().startswith("basic ") or raw.lower().startswith("bearer "):
|
|
17
|
+
return {"Authorization": raw}
|
|
18
|
+
if ":" in raw:
|
|
19
|
+
token = base64.b64encode(raw.encode("utf-8")).decode("ascii")
|
|
20
|
+
return {"Authorization": f"Basic {token}"}
|
|
21
|
+
return {"Authorization": f"Bearer {raw}"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _request(
|
|
25
|
+
method: str,
|
|
26
|
+
url: str,
|
|
27
|
+
*,
|
|
28
|
+
auth_env: str,
|
|
29
|
+
data: bytes | None = None,
|
|
30
|
+
content_type: str | None = None,
|
|
31
|
+
) -> bytes:
|
|
32
|
+
headers = dict(_auth_header(auth_env))
|
|
33
|
+
if content_type:
|
|
34
|
+
headers["Content-Type"] = content_type
|
|
35
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
38
|
+
return resp.read()
|
|
39
|
+
except urllib.error.HTTPError as exc:
|
|
40
|
+
body = exc.read().decode("utf-8", errors="replace")[:500]
|
|
41
|
+
raise RuntimeError(f"Nexus HTTP {exc.code} {url}: {body}") from exc
|
|
42
|
+
except urllib.error.URLError as exc:
|
|
43
|
+
raise RuntimeError(f"Nexus unreachable {url}: {exc}") from exc
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def download_file(url: str, dest: Path, *, auth_env: str) -> None:
|
|
47
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
data = _request("GET", url, auth_env=auth_env)
|
|
49
|
+
dest.write_bytes(data)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def upload_file(url: str, src: Path, *, auth_env: str) -> None:
|
|
53
|
+
data = src.read_bytes()
|
|
54
|
+
_request(
|
|
55
|
+
"PUT",
|
|
56
|
+
url,
|
|
57
|
+
auth_env=auth_env,
|
|
58
|
+
data=data,
|
|
59
|
+
content_type="application/octet-stream",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def join_url(base: str, *parts: str) -> str:
|
|
64
|
+
url = base.rstrip("/")
|
|
65
|
+
for part in parts:
|
|
66
|
+
url = f"{url}/{part.strip('/')}"
|
|
67
|
+
return url
|