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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Background action runner with stdout/stderr bridge for TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import traceback
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from offline_docker.actions import run_action
|
|
13
|
+
from offline_docker.context import ProjectContext
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _LineBridge(io.TextIOBase):
|
|
17
|
+
"""Text stream that forwards complete lines to a callback."""
|
|
18
|
+
|
|
19
|
+
encoding = "utf-8"
|
|
20
|
+
|
|
21
|
+
def __init__(self, on_line: Callable[[str], None]) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._on_line = on_line
|
|
24
|
+
self._buf = ""
|
|
25
|
+
|
|
26
|
+
def writable(self) -> bool:
|
|
27
|
+
return True
|
|
28
|
+
|
|
29
|
+
def write(self, s: str) -> int: # type: ignore[override]
|
|
30
|
+
if not s:
|
|
31
|
+
return 0
|
|
32
|
+
self._buf += s
|
|
33
|
+
while "\n" in self._buf:
|
|
34
|
+
line, self._buf = self._buf.split("\n", 1)
|
|
35
|
+
self._on_line(line.rstrip("\r"))
|
|
36
|
+
return len(s)
|
|
37
|
+
|
|
38
|
+
def flush(self) -> None:
|
|
39
|
+
if self._buf:
|
|
40
|
+
self._on_line(self._buf.rstrip("\r"))
|
|
41
|
+
self._buf = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_action_captured(
|
|
45
|
+
name: str,
|
|
46
|
+
ctx: ProjectContext,
|
|
47
|
+
*,
|
|
48
|
+
on_line: Callable[[str], None],
|
|
49
|
+
on_done: Callable[[int, str | None], None],
|
|
50
|
+
**opts: Any,
|
|
51
|
+
) -> threading.Thread:
|
|
52
|
+
"""Start run_action in a daemon thread; stream prints via on_line."""
|
|
53
|
+
|
|
54
|
+
def worker() -> None:
|
|
55
|
+
bridge = _LineBridge(on_line)
|
|
56
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
57
|
+
sys.stdout = bridge # type: ignore[assignment]
|
|
58
|
+
sys.stderr = bridge # type: ignore[assignment]
|
|
59
|
+
code = 0
|
|
60
|
+
err: str | None = None
|
|
61
|
+
try:
|
|
62
|
+
run_action(name, ctx, **opts)
|
|
63
|
+
except SystemExit as exc:
|
|
64
|
+
if exc.code is None:
|
|
65
|
+
code = 0
|
|
66
|
+
elif isinstance(exc.code, int):
|
|
67
|
+
code = exc.code
|
|
68
|
+
else:
|
|
69
|
+
code = 1
|
|
70
|
+
err = str(exc.code)
|
|
71
|
+
except Exception as exc: # noqa: BLE001 — surface to TUI log
|
|
72
|
+
code = 1
|
|
73
|
+
err = f"{type(exc).__name__}: {exc}"
|
|
74
|
+
on_line(err)
|
|
75
|
+
on_line(traceback.format_exc())
|
|
76
|
+
finally:
|
|
77
|
+
try:
|
|
78
|
+
bridge.flush()
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
pass
|
|
81
|
+
sys.stdout = old_out
|
|
82
|
+
sys.stderr = old_err
|
|
83
|
+
on_done(code, err)
|
|
84
|
+
|
|
85
|
+
thread = threading.Thread(target=worker, name=f"offline-docker-{name}", daemon=True)
|
|
86
|
+
thread.start()
|
|
87
|
+
return thread
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Textual screens for offline-docker TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from textual.app import ComposeResult
|
|
8
|
+
from textual.binding import Binding
|
|
9
|
+
from textual.containers import Horizontal, Vertical
|
|
10
|
+
from textual.screen import ModalScreen, Screen
|
|
11
|
+
from textual.widgets import (
|
|
12
|
+
Button,
|
|
13
|
+
Checkbox,
|
|
14
|
+
DataTable,
|
|
15
|
+
Footer,
|
|
16
|
+
Header,
|
|
17
|
+
Input,
|
|
18
|
+
Label,
|
|
19
|
+
ListItem,
|
|
20
|
+
ListView,
|
|
21
|
+
RichLog,
|
|
22
|
+
Static,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from offline_docker.actions import PROJECT_ACTIONS
|
|
26
|
+
from offline_docker.discover import format_discovery_report
|
|
27
|
+
from offline_docker.status import CacheStatusReport, collect_cache_status
|
|
28
|
+
from offline_docker.tui.runner import run_action_captured
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandListView(ListView):
|
|
32
|
+
"""Click highlights an item; Enter posts Selected (run)."""
|
|
33
|
+
|
|
34
|
+
def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None:
|
|
35
|
+
event.stop()
|
|
36
|
+
self.focus()
|
|
37
|
+
self.index = self._nodes.index(event.item)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# User-facing command list (order matters).
|
|
41
|
+
_COMMANDS: list[tuple[str, str]] = [
|
|
42
|
+
("discover", "Discover dependencies"),
|
|
43
|
+
("cache-check", "Validate offline-cache"),
|
|
44
|
+
("cache", "Download full offline-cache"),
|
|
45
|
+
("cache-wheels", "Download wheels"),
|
|
46
|
+
("cache-debs", "Download debs"),
|
|
47
|
+
("cache-npm", "Download npm cache"),
|
|
48
|
+
("cache-yarn", "Download yarn cache"),
|
|
49
|
+
("cache-images", "Download docker images"),
|
|
50
|
+
("cache-trivy", "Download Trivy DB"),
|
|
51
|
+
("cache-load", "Load docker images"),
|
|
52
|
+
("build", "Build offline (base/backend/frontend)"),
|
|
53
|
+
("build-base", "Build base offline"),
|
|
54
|
+
("up", "Compose up offline"),
|
|
55
|
+
("up-online", "Up online (compose_online)"),
|
|
56
|
+
("down", "Compose down"),
|
|
57
|
+
("scan:backend", "Trivy scan backend"),
|
|
58
|
+
("scan:frontend", "Trivy scan frontend"),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class DiscoverModal(ModalScreen[None]):
|
|
63
|
+
"""Modal with discover report text."""
|
|
64
|
+
|
|
65
|
+
BINDINGS = [Binding("escape", "dismiss", "Close", show=True)]
|
|
66
|
+
|
|
67
|
+
def __init__(self, report: str) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._report = report
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
with Vertical(id="discover-modal"):
|
|
73
|
+
yield Static("Discover report", classes="title")
|
|
74
|
+
log = RichLog(highlight=False, markup=False, id="discover-log")
|
|
75
|
+
yield log
|
|
76
|
+
yield Button("Close", id="close-discover", variant="primary")
|
|
77
|
+
|
|
78
|
+
def on_mount(self) -> None:
|
|
79
|
+
log = self.query_one("#discover-log", RichLog)
|
|
80
|
+
for line in self._report.splitlines():
|
|
81
|
+
log.write(line)
|
|
82
|
+
|
|
83
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
84
|
+
if event.button.id == "close-discover":
|
|
85
|
+
self.dismiss()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class HomeScreen(Screen[None]):
|
|
89
|
+
"""Project path, cache status, commands, live log."""
|
|
90
|
+
|
|
91
|
+
BINDINGS = [
|
|
92
|
+
Binding("r", "refresh", "Refresh", show=True),
|
|
93
|
+
Binding("d", "discover", "Discover", show=True),
|
|
94
|
+
Binding("q", "quit", "Quit", show=True),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
CSS = """
|
|
98
|
+
#project-row {
|
|
99
|
+
height: auto;
|
|
100
|
+
margin: 0 1;
|
|
101
|
+
}
|
|
102
|
+
#project-input {
|
|
103
|
+
width: 1fr;
|
|
104
|
+
}
|
|
105
|
+
#summary {
|
|
106
|
+
margin: 0 1;
|
|
107
|
+
height: 1;
|
|
108
|
+
color: $text-muted;
|
|
109
|
+
}
|
|
110
|
+
#status-table {
|
|
111
|
+
height: 12;
|
|
112
|
+
margin: 0 1;
|
|
113
|
+
}
|
|
114
|
+
#flags {
|
|
115
|
+
height: auto;
|
|
116
|
+
margin: 0 1;
|
|
117
|
+
}
|
|
118
|
+
#commands {
|
|
119
|
+
height: 10;
|
|
120
|
+
margin: 0 1;
|
|
121
|
+
border: solid $primary;
|
|
122
|
+
}
|
|
123
|
+
#run-log {
|
|
124
|
+
height: 1fr;
|
|
125
|
+
margin: 0 1 1 1;
|
|
126
|
+
border: solid $accent;
|
|
127
|
+
}
|
|
128
|
+
#actions-row {
|
|
129
|
+
height: auto;
|
|
130
|
+
margin: 0 1;
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self) -> None:
|
|
135
|
+
super().__init__()
|
|
136
|
+
self._running = False
|
|
137
|
+
self._selected_command: str | None = None
|
|
138
|
+
|
|
139
|
+
def compose(self) -> ComposeResult:
|
|
140
|
+
app = self.app
|
|
141
|
+
from offline_docker.tui.app import OfflineDockerApp
|
|
142
|
+
|
|
143
|
+
assert isinstance(app, OfflineDockerApp)
|
|
144
|
+
yield Header(show_clock=False)
|
|
145
|
+
with Vertical():
|
|
146
|
+
yield Static("offdock TUI", classes="title")
|
|
147
|
+
with Horizontal(id="project-row"):
|
|
148
|
+
yield Label("Project:")
|
|
149
|
+
yield Input(value=str(app.project), id="project-input")
|
|
150
|
+
yield Button("Load", id="btn-load", variant="primary")
|
|
151
|
+
yield Button("Refresh", id="btn-refresh")
|
|
152
|
+
yield Static("", id="summary")
|
|
153
|
+
yield DataTable(id="status-table", zebra_stripes=True)
|
|
154
|
+
with Horizontal(id="flags"):
|
|
155
|
+
yield Checkbox("no-cache", id="flag-no-cache")
|
|
156
|
+
yield Checkbox("lab (up)", id="flag-lab")
|
|
157
|
+
yield Checkbox("online (up/down)", id="flag-online")
|
|
158
|
+
yield Checkbox("no-build", id="flag-no-build")
|
|
159
|
+
yield Checkbox("no-deps", id="flag-no-deps")
|
|
160
|
+
yield Label("Commands (Enter / Run):")
|
|
161
|
+
yield CommandListView(id="commands", *self._command_items())
|
|
162
|
+
with Horizontal(id="actions-row"):
|
|
163
|
+
yield Button("Run", id="btn-run", variant="success")
|
|
164
|
+
yield Button("Discover report", id="btn-discover")
|
|
165
|
+
yield RichLog(highlight=False, markup=False, id="run-log", wrap=True)
|
|
166
|
+
yield Footer()
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _command_items() -> list[ListItem]:
|
|
170
|
+
items: list[ListItem] = []
|
|
171
|
+
for key, title in _COMMANDS:
|
|
172
|
+
items.append(ListItem(Label(f"{title} ({key})"), id=f"cmd-{key.replace(':', '-')}"))
|
|
173
|
+
return items
|
|
174
|
+
|
|
175
|
+
def on_mount(self) -> None:
|
|
176
|
+
table = self.query_one("#status-table", DataTable)
|
|
177
|
+
table.add_columns("Level", "Label", "Detail")
|
|
178
|
+
table.cursor_type = "row"
|
|
179
|
+
self.action_refresh()
|
|
180
|
+
|
|
181
|
+
def _app(self):
|
|
182
|
+
from offline_docker.tui.app import OfflineDockerApp
|
|
183
|
+
|
|
184
|
+
app = self.app
|
|
185
|
+
assert isinstance(app, OfflineDockerApp)
|
|
186
|
+
return app
|
|
187
|
+
|
|
188
|
+
def action_refresh(self) -> None:
|
|
189
|
+
self._reload_status()
|
|
190
|
+
|
|
191
|
+
def action_discover(self) -> None:
|
|
192
|
+
self._show_discover()
|
|
193
|
+
|
|
194
|
+
def action_quit(self) -> None:
|
|
195
|
+
self.app.exit()
|
|
196
|
+
|
|
197
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
198
|
+
bid = event.button.id
|
|
199
|
+
if bid == "btn-load":
|
|
200
|
+
self._load_project_from_input()
|
|
201
|
+
elif bid == "btn-refresh":
|
|
202
|
+
self._reload_status()
|
|
203
|
+
elif bid == "btn-discover":
|
|
204
|
+
self._show_discover()
|
|
205
|
+
elif bid == "btn-run":
|
|
206
|
+
self._run_selected()
|
|
207
|
+
|
|
208
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
209
|
+
idx = event.list_view.index
|
|
210
|
+
if idx is None or idx < 0 or idx >= len(_COMMANDS):
|
|
211
|
+
return
|
|
212
|
+
self._selected_command = _COMMANDS[idx][0]
|
|
213
|
+
|
|
214
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
215
|
+
idx = event.list_view.index
|
|
216
|
+
if idx is None or idx < 0 or idx >= len(_COMMANDS):
|
|
217
|
+
return
|
|
218
|
+
self._selected_command = _COMMANDS[idx][0]
|
|
219
|
+
self._run_selected()
|
|
220
|
+
|
|
221
|
+
def _load_project_from_input(self) -> None:
|
|
222
|
+
raw = self.query_one("#project-input", Input).value.strip()
|
|
223
|
+
if not raw:
|
|
224
|
+
self.notify("Укажите путь к проекту", severity="warning")
|
|
225
|
+
return
|
|
226
|
+
path = Path(raw).expanduser().resolve()
|
|
227
|
+
if not path.is_dir():
|
|
228
|
+
self.notify(f"Каталог не найден: {path}", severity="error")
|
|
229
|
+
return
|
|
230
|
+
try:
|
|
231
|
+
self._app().set_project(path)
|
|
232
|
+
except Exception as exc: # noqa: BLE001
|
|
233
|
+
self.notify(f"Не удалось загрузить конфиг: {exc}", severity="error")
|
|
234
|
+
return
|
|
235
|
+
self.notify(f"Проект: {path}")
|
|
236
|
+
self._reload_status()
|
|
237
|
+
|
|
238
|
+
def _reload_status(self) -> None:
|
|
239
|
+
app = self._app()
|
|
240
|
+
try:
|
|
241
|
+
report = collect_cache_status(app.ctx)
|
|
242
|
+
except Exception as exc: # noqa: BLE001
|
|
243
|
+
self.notify(f"Status error: {exc}", severity="error")
|
|
244
|
+
return
|
|
245
|
+
self._fill_status(report)
|
|
246
|
+
|
|
247
|
+
def _fill_status(self, report: CacheStatusReport) -> None:
|
|
248
|
+
summary = self.query_one("#summary", Static)
|
|
249
|
+
mode = " soft" if report.soft else ""
|
|
250
|
+
summary.update(
|
|
251
|
+
f"cache={report.offline_cache} "
|
|
252
|
+
f"OK={report.ok_count} WARN={report.warn_count} ERROR={report.error_count}{mode} "
|
|
253
|
+
f"resolve={' -> '.join(report.resolve_order)}"
|
|
254
|
+
)
|
|
255
|
+
table = self.query_one("#status-table", DataTable)
|
|
256
|
+
table.clear()
|
|
257
|
+
for item in report.items:
|
|
258
|
+
table.add_row(item.level.upper(), item.label, item.detail or "")
|
|
259
|
+
|
|
260
|
+
def _show_discover(self) -> None:
|
|
261
|
+
try:
|
|
262
|
+
text = format_discovery_report(self._app().ctx.config)
|
|
263
|
+
except Exception as exc: # noqa: BLE001
|
|
264
|
+
self.notify(f"Discover error: {exc}", severity="error")
|
|
265
|
+
return
|
|
266
|
+
self.app.push_screen(DiscoverModal(text))
|
|
267
|
+
|
|
268
|
+
def _flag(self, widget_id: str) -> bool:
|
|
269
|
+
return self.query_one(f"#{widget_id}", Checkbox).value
|
|
270
|
+
|
|
271
|
+
def _run_selected(self) -> None:
|
|
272
|
+
if self._running:
|
|
273
|
+
self.notify("Уже выполняется команда", severity="warning")
|
|
274
|
+
return
|
|
275
|
+
key = self._selected_command
|
|
276
|
+
if not key:
|
|
277
|
+
lv = self.query_one("#commands", ListView)
|
|
278
|
+
if lv.index is not None and 0 <= lv.index < len(_COMMANDS):
|
|
279
|
+
key = _COMMANDS[lv.index][0]
|
|
280
|
+
if not key:
|
|
281
|
+
self.notify("Выберите команду", severity="warning")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
name = key
|
|
285
|
+
opts: dict = {
|
|
286
|
+
"no_cache": self._flag("flag-no-cache"),
|
|
287
|
+
"lab": self._flag("flag-lab"),
|
|
288
|
+
"online": self._flag("flag-online"),
|
|
289
|
+
"no_build": self._flag("flag-no-build"),
|
|
290
|
+
"no_deps": self._flag("flag-no-deps"),
|
|
291
|
+
}
|
|
292
|
+
if key.startswith("scan:"):
|
|
293
|
+
name = "scan"
|
|
294
|
+
opts["target"] = key.split(":", 1)[1]
|
|
295
|
+
if name == "up-online":
|
|
296
|
+
name = "up"
|
|
297
|
+
opts["online"] = True
|
|
298
|
+
opts["lab"] = False
|
|
299
|
+
if opts.get("lab") and opts.get("online"):
|
|
300
|
+
self.notify("--lab и --online взаимоисключающие; снимите один флаг", severity="warning")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
if name not in PROJECT_ACTIONS:
|
|
304
|
+
self.notify(f"Неизвестная команда: {name}", severity="error")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
log = self.query_one("#run-log", RichLog)
|
|
308
|
+
log.clear()
|
|
309
|
+
log.write(f">>> {name} {opts}")
|
|
310
|
+
self._running = True
|
|
311
|
+
self.query_one("#btn-run", Button).disabled = True
|
|
312
|
+
|
|
313
|
+
app = self._app()
|
|
314
|
+
|
|
315
|
+
def on_line(line: str) -> None:
|
|
316
|
+
app.call_from_thread(log.write, line)
|
|
317
|
+
|
|
318
|
+
def on_done(code: int, err: str | None) -> None:
|
|
319
|
+
def finish() -> None:
|
|
320
|
+
self._running = False
|
|
321
|
+
self.query_one("#btn-run", Button).disabled = False
|
|
322
|
+
if err:
|
|
323
|
+
log.write(f"!!! {err}")
|
|
324
|
+
log.write(f"=== exit {code} ===")
|
|
325
|
+
if code == 0:
|
|
326
|
+
self.notify(f"{name}: OK")
|
|
327
|
+
self._reload_status()
|
|
328
|
+
else:
|
|
329
|
+
self.notify(f"{name}: failed ({code})", severity="error")
|
|
330
|
+
|
|
331
|
+
app.call_from_thread(finish)
|
|
332
|
+
|
|
333
|
+
run_action_captured(name, app.ctx, on_line=on_line, on_done=on_done, **opts)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""User-level sources config (~/.config/offline-docker/sources.json)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def user_config_dir() -> Path:
|
|
13
|
+
override = os.environ.get("OFFLINE_DOCKER_CONFIG_DIR")
|
|
14
|
+
if override:
|
|
15
|
+
return Path(override).expanduser().resolve()
|
|
16
|
+
if sys.platform == "win32":
|
|
17
|
+
base = os.environ.get("APPDATA")
|
|
18
|
+
if base:
|
|
19
|
+
return Path(base) / "offline-docker"
|
|
20
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
21
|
+
if xdg:
|
|
22
|
+
return Path(xdg).expanduser() / "offline-docker"
|
|
23
|
+
return Path.home() / ".config" / "offline-docker"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_sources_path() -> Path:
|
|
27
|
+
return user_config_dir() / "sources.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def local_only_dict() -> dict[str, Any]:
|
|
31
|
+
"""Offline / corp VM: только offline-cache (без HTTP index в resolve.order)."""
|
|
32
|
+
return {
|
|
33
|
+
"sources": [
|
|
34
|
+
{"name": "local", "type": "filesystem", "enabled": True},
|
|
35
|
+
],
|
|
36
|
+
"resolve": {
|
|
37
|
+
"order": ["local"],
|
|
38
|
+
"fill_local": True,
|
|
39
|
+
"on_miss": "fail",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def online_fill_dict() -> dict[str, Any]:
|
|
45
|
+
"""Машина с сетью: local + PyPI/npmjs upstream для download-* (fill_local)."""
|
|
46
|
+
return {
|
|
47
|
+
"sources": [
|
|
48
|
+
{"name": "local", "type": "filesystem", "enabled": True},
|
|
49
|
+
{
|
|
50
|
+
"name": "upstream",
|
|
51
|
+
"type": "upstream",
|
|
52
|
+
"enabled": True,
|
|
53
|
+
"pypi_index": "https://pypi.org/simple",
|
|
54
|
+
"npm_registry": "https://registry.npmjs.org/",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
"resolve": {
|
|
58
|
+
"order": ["local", "upstream"],
|
|
59
|
+
"fill_local": True,
|
|
60
|
+
"on_miss": "fail",
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_user_sources() -> dict[str, Any] | None:
|
|
66
|
+
path = user_sources_path()
|
|
67
|
+
if not path.is_file():
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
71
|
+
except (OSError, json.JSONDecodeError):
|
|
72
|
+
return None
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
return None
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def save_user_sources(data: dict[str, Any]) -> Path:
|
|
79
|
+
path = user_sources_path()
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path.write_text(
|
|
82
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
83
|
+
encoding="utf-8",
|
|
84
|
+
)
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def user_config_exists() -> bool:
|
|
89
|
+
return user_sources_path().is_file()
|
offline_docker/utils.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Shared helpers: hashes, docker mount paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def docker_mount_path(path: Path | str) -> str:
|
|
11
|
+
"""Host path for Docker -v mounts (Windows: D:/... with forward slashes)."""
|
|
12
|
+
p = Path(path).resolve()
|
|
13
|
+
return str(p).replace("\\", "/")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def file_sha256(path: Path) -> str:
|
|
17
|
+
h = hashlib.sha256()
|
|
18
|
+
with path.open("rb") as f:
|
|
19
|
+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
20
|
+
h.update(chunk)
|
|
21
|
+
return h.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def combined_files_hash(paths: list[Path]) -> str:
|
|
25
|
+
parts = "|".join(file_sha256(p) for p in paths if p.is_file())
|
|
26
|
+
return hashlib.sha256(parts.encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_torch_version(path: Path) -> str:
|
|
30
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
31
|
+
line = line.strip()
|
|
32
|
+
if line.startswith("torch=="):
|
|
33
|
+
return line.split("==", 1)[1].strip()
|
|
34
|
+
raise RuntimeError(f"torch==VERSION not found in {path}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_APT_INSTALL_RE = re.compile(
|
|
38
|
+
r"apt-get\s+install\b(?:[^\n\\]|\\\s*\n)+",
|
|
39
|
+
re.MULTILINE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_apt_packages_from_dockerfiles(*dockerfiles: Path) -> list[str]:
|
|
44
|
+
"""Extract package names from apt-get install lines in Dockerfiles."""
|
|
45
|
+
packages: list[str] = []
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
skip = {
|
|
48
|
+
"apt-get",
|
|
49
|
+
"install",
|
|
50
|
+
"-y",
|
|
51
|
+
"--no-install-recommends",
|
|
52
|
+
"&&",
|
|
53
|
+
"rm",
|
|
54
|
+
"-rf",
|
|
55
|
+
"/var/lib/apt/lists/*",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
for dockerfile in dockerfiles:
|
|
59
|
+
if not dockerfile.is_file():
|
|
60
|
+
continue
|
|
61
|
+
text = dockerfile.read_text(encoding="utf-8", errors="replace")
|
|
62
|
+
for block in _APT_INSTALL_RE.findall(text):
|
|
63
|
+
block = block.replace("\\\n", " ")
|
|
64
|
+
for token in re.split(r"[\s\\]+", block):
|
|
65
|
+
token = token.strip().strip('"').strip("'")
|
|
66
|
+
if not token or token in skip or token.startswith("-"):
|
|
67
|
+
continue
|
|
68
|
+
if token.startswith("/") or "=" in token:
|
|
69
|
+
continue
|
|
70
|
+
if token not in seen:
|
|
71
|
+
seen.add(token)
|
|
72
|
+
packages.append(token)
|
|
73
|
+
return packages
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Validate offline-cache completeness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from offline_docker.context import ProjectContext
|
|
6
|
+
from offline_docker.status import collect_cache_status
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _format_item_line(level: str, label: str, detail: str) -> str:
|
|
10
|
+
if level == "ok":
|
|
11
|
+
if not detail:
|
|
12
|
+
return f"OK: {label}"
|
|
13
|
+
if detail.startswith("/") or ":\\" in detail or detail.startswith("\\\\"):
|
|
14
|
+
return f"OK: {label} -> {detail}"
|
|
15
|
+
if detail.endswith("файлов") or detail.isdigit():
|
|
16
|
+
return f"OK: {label} - {detail}"
|
|
17
|
+
if detail.startswith("соответствует"):
|
|
18
|
+
return f"OK: {label} {detail}" if "hash" in label or "cache" in label else f"OK: {label} {detail}"
|
|
19
|
+
# paths / archive names / generic
|
|
20
|
+
if "/" in detail or "\\" in detail or detail.endswith(".tar") or detail.endswith(".tar.gz"):
|
|
21
|
+
return f"OK: {label} -> {detail}"
|
|
22
|
+
return f"OK: {label} - {detail}" if " " in detail else f"OK: {label} -> {detail}"
|
|
23
|
+
if level == "skip":
|
|
24
|
+
return f"SKIP: {label} ({detail})" if detail else f"SKIP: {label}"
|
|
25
|
+
prefix = "WARN" if level == "warn" else "ERROR"
|
|
26
|
+
msg = detail or label
|
|
27
|
+
return f"{prefix}: {msg}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_offline_cache(ctx: ProjectContext) -> None:
|
|
31
|
+
report = collect_cache_status(ctx)
|
|
32
|
+
|
|
33
|
+
print("=== cache check ===")
|
|
34
|
+
print(f"Project: {report.project}")
|
|
35
|
+
print(f"OFFLINE_CACHE: {report.offline_cache}")
|
|
36
|
+
print(f"Resolve: {' -> '.join(report.resolve_order)}")
|
|
37
|
+
print(f"on_miss: {report.on_miss}")
|
|
38
|
+
if report.soft:
|
|
39
|
+
print("Mode: soft (WARN instead of ERROR when remotes configured)")
|
|
40
|
+
print()
|
|
41
|
+
|
|
42
|
+
for item in report.items:
|
|
43
|
+
line = _format_item_line(item.level, item.label, item.detail)
|
|
44
|
+
if item.level in ("warn", "error"):
|
|
45
|
+
print(line, flush=True)
|
|
46
|
+
else:
|
|
47
|
+
print(line)
|
|
48
|
+
|
|
49
|
+
print()
|
|
50
|
+
if report.error_count:
|
|
51
|
+
print(f"=== FAILED: {report.error_count} ошибок ===", flush=True)
|
|
52
|
+
raise SystemExit(1)
|
|
53
|
+
print("=== cache check: OK ===")
|