docker-diff 0.0.6__py3-none-any.whl → 0.0.7__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.
- {docker_diff-0.0.6.dist-info → docker_diff-0.0.7.dist-info}/METADATA +2 -1
- docker_diff-0.0.7.dist-info/RECORD +10 -0
- docker_diff_pkg/_version.py +2 -2
- docker_diff_pkg/cli.py +355 -201
- docker_diff-0.0.6.dist-info/RECORD +0 -10
- {docker_diff-0.0.6.dist-info → docker_diff-0.0.7.dist-info}/WHEEL +0 -0
- {docker_diff-0.0.6.dist-info → docker_diff-0.0.7.dist-info}/entry_points.txt +0 -0
- {docker_diff-0.0.6.dist-info → docker_diff-0.0.7.dist-info}/licenses/LICENSE +0 -0
- {docker_diff-0.0.6.dist-info → docker_diff-0.0.7.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: docker-diff
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.7
|
4
4
|
Summary: Docker Image Comparison Database Manager
|
5
5
|
License: This is free and unencumbered software released into the public domain.
|
6
6
|
|
@@ -30,4 +30,5 @@ License: This is free and unencumbered software released into the public domain.
|
|
30
30
|
Project-URL: Repository, https://github.com/eapolinario/docker-diff
|
31
31
|
Requires-Python: >=3.10
|
32
32
|
License-File: LICENSE
|
33
|
+
Requires-Dist: libsql-client>=0.3
|
33
34
|
Dynamic: license-file
|
@@ -0,0 +1,10 @@
|
|
1
|
+
docker_diff-0.0.7.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
|
2
|
+
docker_diff_pkg/__init__.py,sha256=aG5WUO74Z5ax04A5iwh6thCpHcFXBsdH5v-ph-uUJuI,321
|
3
|
+
docker_diff_pkg/_version.py,sha256=AV58KqMkBGaCvmPdbd3g9huyNXfIVxjw8QbCMdaeivU,704
|
4
|
+
docker_diff_pkg/cli.py,sha256=C6LYpgFGmiZsBZ8MO4TJKmtKMvg1EqB6qYlpilbq6pI,21549
|
5
|
+
docker_diff_pkg/schema.sql,sha256=xI3kOohbUEzwFhmIGQs2_abN_tJn1BBdAya7tVbw6n8,5477
|
6
|
+
docker_diff-0.0.7.dist-info/METADATA,sha256=na6gAxdT1d2_cZeuQMqfFEu9k7gqUKhPPm-wGGRBr8c,1688
|
7
|
+
docker_diff-0.0.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
+
docker_diff-0.0.7.dist-info/entry_points.txt,sha256=nEDTWZsgB5fRJgeXHJbXJyaHKMmKyN9Qypf3X78tosw,57
|
9
|
+
docker_diff-0.0.7.dist-info/top_level.txt,sha256=GB5Qrc3AH1pk0543cQz9SRNr5Fatrj-c0Cdp3rCvTM8,16
|
10
|
+
docker_diff-0.0.7.dist-info/RECORD,,
|
docker_diff_pkg/_version.py
CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
28
28
|
commit_id: COMMIT_ID
|
29
29
|
__commit_id__: COMMIT_ID
|
30
30
|
|
31
|
-
__version__ = version = '0.0.
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
31
|
+
__version__ = version = '0.0.7'
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 7)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
docker_diff_pkg/cli.py
CHANGED
@@ -2,6 +2,7 @@
|
|
2
2
|
"""
|
3
3
|
Docker Image Comparison Database Manager
|
4
4
|
Provides functions to store and query Docker image file comparison results in SQLite
|
5
|
+
Supports local SQLite via `sqlite3` and remote Turso via `libsql`.
|
5
6
|
"""
|
6
7
|
|
7
8
|
import sqlite3
|
@@ -10,72 +11,211 @@ import subprocess
|
|
10
11
|
import sys
|
11
12
|
from datetime import datetime
|
12
13
|
from pathlib import Path
|
13
|
-
from typing import List, Dict, Optional, Tuple
|
14
|
+
from typing import List, Dict, Optional, Tuple, Any
|
15
|
+
import os
|
16
|
+
|
17
|
+
try:
|
18
|
+
# Optional: Turso/libsql client for remote DB (sync wrapper)
|
19
|
+
from libsql_client.sync import create_client_sync # type: ignore
|
20
|
+
except Exception: # pragma: no cover - optional dependency
|
21
|
+
create_client_sync = None # type: ignore
|
14
22
|
|
15
23
|
class DockerImageDB:
|
16
24
|
def __init__(self, db_path: str = "docker_images.db"):
|
25
|
+
"""
|
26
|
+
Initialize DB access. If environment variables for Turso/libsql are set,
|
27
|
+
connect to the remote database. Otherwise, use local SQLite file.
|
28
|
+
|
29
|
+
Env vars recognized (first found wins):
|
30
|
+
- TURSO_DATABASE_URL / LIBSQL_URL
|
31
|
+
- TURSO_AUTH_TOKEN / LIBSQL_AUTH_TOKEN
|
32
|
+
"""
|
17
33
|
self.db_path = db_path
|
34
|
+
|
35
|
+
# Detect remote libsql configuration via env vars
|
36
|
+
self.libsql_url = os.getenv("TURSO_DATABASE_URL") or os.getenv("LIBSQL_URL")
|
37
|
+
self.libsql_token = os.getenv("TURSO_AUTH_TOKEN") or os.getenv("LIBSQL_AUTH_TOKEN")
|
38
|
+
self.use_libsql = bool(self.libsql_url)
|
39
|
+
|
40
|
+
self.client = None
|
41
|
+
if self.use_libsql:
|
42
|
+
if create_client_sync is None:
|
43
|
+
raise RuntimeError(
|
44
|
+
"libsql-client is not installed but LIBSQL/TURSO env vars are set. "
|
45
|
+
"Install dependency or unset the env vars."
|
46
|
+
)
|
47
|
+
# Create libsql client
|
48
|
+
self.client = create_client_sync(url=self.libsql_url, auth_token=self.libsql_token)
|
49
|
+
|
18
50
|
self.init_database()
|
19
51
|
|
52
|
+
def close(self) -> None:
|
53
|
+
"""Close any remote DB client resources."""
|
54
|
+
try:
|
55
|
+
if self.use_libsql and self.client is not None and hasattr(self.client, "close"):
|
56
|
+
# ClientSync.close() is synchronous
|
57
|
+
self.client.close() # type: ignore[call-arg]
|
58
|
+
except Exception:
|
59
|
+
# Best-effort shutdown; avoid masking exit with close errors
|
60
|
+
pass
|
61
|
+
|
20
62
|
def init_database(self):
|
21
|
-
"""Initialize the database with schema"""
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
63
|
+
"""Initialize the database with schema for either backend"""
|
64
|
+
# Read schema using importlib.resources for proper packaging
|
65
|
+
try:
|
66
|
+
import importlib.resources as pkg_resources
|
67
|
+
with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
|
68
|
+
schema_content = f.read()
|
69
|
+
except (ImportError, AttributeError):
|
70
|
+
# Fallback for Python < 3.9
|
71
|
+
import importlib_resources as pkg_resources
|
72
|
+
with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
|
73
|
+
schema_content = f.read()
|
74
|
+
|
75
|
+
self._executescript(schema_content)
|
76
|
+
|
77
|
+
# ---- Internal helpers to abstract DB backend ----
|
78
|
+
class _Result:
|
79
|
+
def __init__(self, rows: List[Tuple[Any, ...]] | None = None, columns: List[str] | None = None, last_row_id: Optional[int] = None):
|
80
|
+
self.rows = rows or []
|
81
|
+
self.columns = columns or []
|
82
|
+
self.last_row_id = last_row_id
|
83
|
+
|
84
|
+
def _to_tuples(self, rows: Any) -> List[Tuple[Any, ...]]:
|
85
|
+
"""Normalize libsql rows (list[list|tuple|dict]) to list of tuples."""
|
86
|
+
if rows is None:
|
87
|
+
return []
|
88
|
+
if not rows:
|
89
|
+
return []
|
90
|
+
first = rows[0]
|
91
|
+
if isinstance(first, dict):
|
92
|
+
# Keep insertion order of dict; assume all rows share keys order
|
93
|
+
return [tuple(r.values()) for r in rows]
|
94
|
+
if isinstance(first, (list, tuple)):
|
95
|
+
return [tuple(r) for r in rows]
|
96
|
+
# Fallback single column scalar rows
|
97
|
+
return [(r,) for r in rows]
|
98
|
+
|
99
|
+
def _exec(self, sql: str, params: Tuple[Any, ...] | List[Any] | None = None) -> "DockerImageDB._Result":
|
100
|
+
if self.use_libsql:
|
101
|
+
res = self.client.execute(sql, params or []) # type: ignore[attr-defined]
|
102
|
+
# libsql ResultSet rows are Row objects; convert to tuples
|
103
|
+
rs_rows = getattr(res, "rows", [])
|
104
|
+
rows: List[Tuple[Any, ...]] = []
|
105
|
+
for r in rs_rows:
|
106
|
+
if hasattr(r, "astuple"):
|
107
|
+
rows.append(tuple(r.astuple())) # type: ignore[attr-defined]
|
108
|
+
elif isinstance(r, (list, tuple)):
|
109
|
+
rows.append(tuple(r))
|
110
|
+
else:
|
111
|
+
# Sequence fallback
|
112
|
+
try:
|
113
|
+
rows.append(tuple(r)) # type: ignore[arg-type]
|
114
|
+
except Exception:
|
115
|
+
rows.append((r,))
|
116
|
+
columns = list(getattr(res, "columns", []) or [])
|
117
|
+
last_row_id = getattr(res, "last_insert_rowid", None)
|
118
|
+
return DockerImageDB._Result(rows, columns, last_row_id)
|
119
|
+
else:
|
120
|
+
with sqlite3.connect(self.db_path) as conn:
|
121
|
+
cur = conn.cursor()
|
122
|
+
if params is None:
|
123
|
+
cur.execute(sql)
|
124
|
+
else:
|
125
|
+
cur.execute(sql, params)
|
126
|
+
rows = cur.fetchall() if cur.description else []
|
127
|
+
columns = [d[0] for d in cur.description] if cur.description else []
|
128
|
+
return DockerImageDB._Result(rows, columns, cur.lastrowid)
|
129
|
+
|
130
|
+
def _executemany(self, sql: str, seq_params: List[Tuple[Any, ...]]):
|
131
|
+
if not seq_params:
|
132
|
+
return
|
133
|
+
if self.use_libsql:
|
134
|
+
# Execute sequentially for compatibility
|
135
|
+
for p in seq_params:
|
136
|
+
self.client.execute(sql, list(p)) # type: ignore[attr-defined]
|
137
|
+
else:
|
138
|
+
with sqlite3.connect(self.db_path) as conn:
|
139
|
+
cur = conn.cursor()
|
140
|
+
cur.executemany(sql, seq_params)
|
141
|
+
|
142
|
+
def _executescript(self, script: str):
|
143
|
+
if self.use_libsql:
|
144
|
+
# Split naive on ';' and run statements; ignore empty
|
145
|
+
statements = [s.strip() for s in script.split(';') if s.strip()]
|
146
|
+
for stmt in statements:
|
147
|
+
self.client.execute(stmt) # type: ignore[attr-defined]
|
148
|
+
else:
|
149
|
+
with sqlite3.connect(self.db_path) as conn:
|
150
|
+
conn.executescript(script)
|
35
151
|
|
36
152
|
def add_image(self, name: str, digest: str = None, size_bytes: int = None) -> int:
|
37
153
|
"""Add an image to the database, return image_id"""
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
return cursor.fetchone()[0]
|
154
|
+
self._exec(
|
155
|
+
"""
|
156
|
+
INSERT OR IGNORE INTO images (name, digest, size_bytes)
|
157
|
+
VALUES (?, ?, ?)
|
158
|
+
""",
|
159
|
+
(name, digest, size_bytes),
|
160
|
+
)
|
161
|
+
row = self._exec("SELECT id FROM images WHERE name = ?", (name,)).rows
|
162
|
+
return int(row[0][0]) if row else -1
|
48
163
|
|
49
164
|
def add_files_for_image(self, image_id: int, files: List[Dict]):
|
50
165
|
"""Add file listings for an image"""
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
166
|
+
# Clear existing files for this image
|
167
|
+
self._exec("DELETE FROM files WHERE image_id = ?", (image_id,))
|
168
|
+
|
169
|
+
# Insert new files
|
170
|
+
file_data: List[Tuple[Any, ...]] = []
|
171
|
+
for file_info in files:
|
172
|
+
file_data.append((
|
173
|
+
image_id,
|
174
|
+
file_info['path'],
|
175
|
+
file_info.get('size', 0),
|
176
|
+
file_info.get('mode'),
|
177
|
+
file_info.get('mtime'),
|
178
|
+
file_info.get('type', 'file'),
|
179
|
+
file_info.get('checksum')
|
180
|
+
))
|
181
|
+
|
182
|
+
self._executemany(
|
183
|
+
"""
|
184
|
+
INSERT INTO files
|
185
|
+
(image_id, file_path, file_size, file_mode, modified_time, file_type, checksum)
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
187
|
+
""",
|
188
|
+
file_data,
|
189
|
+
)
|
75
190
|
|
76
|
-
def scan_image(self, image_name: str) -> int:
|
191
|
+
def scan_image(self, image_name: str, force: bool = False) -> int:
|
77
192
|
"""Scan a Docker image and store its files"""
|
78
193
|
print(f"Scanning {image_name}...")
|
194
|
+
|
195
|
+
# Helper to parse tag safely: consider last ':' after last '/'
|
196
|
+
def _get_tag(name: str) -> str:
|
197
|
+
slash_idx = name.rfind('/')
|
198
|
+
colon_idx = name.rfind(':')
|
199
|
+
if colon_idx > slash_idx:
|
200
|
+
return name[colon_idx + 1 :]
|
201
|
+
return 'latest'
|
202
|
+
|
203
|
+
# If image already exists with files and tag is not 'latest', skip re-scan
|
204
|
+
tag = _get_tag(image_name).lower() if image_name else 'latest'
|
205
|
+
if not force and tag != 'latest':
|
206
|
+
rows = self._exec(
|
207
|
+
"""
|
208
|
+
SELECT i.id, COUNT(f.id) as file_count
|
209
|
+
FROM images i
|
210
|
+
LEFT JOIN files f ON i.id = f.image_id
|
211
|
+
WHERE i.name = ?
|
212
|
+
GROUP BY i.id
|
213
|
+
""",
|
214
|
+
(image_name,),
|
215
|
+
).rows
|
216
|
+
if rows and rows[0][1] and int(rows[0][1]) > 0:
|
217
|
+
print(f" Skipping re-scan for {image_name} (already in DB with files)")
|
218
|
+
return int(rows[0][0])
|
79
219
|
|
80
220
|
# Add image to database
|
81
221
|
image_id = self.add_image(image_name)
|
@@ -108,25 +248,31 @@ class DockerImageDB:
|
|
108
248
|
|
109
249
|
def create_comparison(self, name: str, description: str = None) -> int:
|
110
250
|
"""Create a new comparison session"""
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
251
|
+
res = self._exec(
|
252
|
+
"""
|
253
|
+
INSERT INTO comparisons (name, description)
|
254
|
+
VALUES (?, ?)
|
255
|
+
""",
|
256
|
+
(name, description),
|
257
|
+
)
|
258
|
+
if self.use_libsql:
|
259
|
+
# Fetch id via last row if needed
|
260
|
+
row = self._exec("SELECT id FROM comparisons WHERE name = ? ORDER BY id DESC LIMIT 1", (name,)).rows
|
261
|
+
return int(row[0][0]) if row else -1
|
262
|
+
return int(res.last_row_id or -1)
|
118
263
|
|
119
264
|
def add_images_to_comparison(self, comparison_id: int, image_ids: List[int]):
|
120
265
|
"""Add images to a comparison"""
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
266
|
+
for image_id in image_ids:
|
267
|
+
self._exec(
|
268
|
+
"""
|
269
|
+
INSERT OR IGNORE INTO comparison_images (comparison_id, image_id)
|
270
|
+
VALUES (?, ?)
|
271
|
+
""",
|
272
|
+
(comparison_id, image_id),
|
273
|
+
)
|
128
274
|
|
129
|
-
def compare_images(self, image_names: List[str], comparison_name: str = None) -> int:
|
275
|
+
def compare_images(self, image_names: List[str], comparison_name: str = None, *, force: bool = False) -> int:
|
130
276
|
"""Compare multiple images and store results"""
|
131
277
|
if not comparison_name:
|
132
278
|
comparison_name = f"Comparison_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
@@ -134,7 +280,7 @@ class DockerImageDB:
|
|
134
280
|
# Scan all images
|
135
281
|
image_ids = []
|
136
282
|
for image_name in image_names:
|
137
|
-
image_id = self.scan_image(image_name)
|
283
|
+
image_id = self.scan_image(image_name, force=force)
|
138
284
|
image_ids.append(image_id)
|
139
285
|
|
140
286
|
# Create comparison
|
@@ -153,86 +299,93 @@ class DockerImageDB:
|
|
153
299
|
|
154
300
|
def _generate_file_differences(self, comparison_id: int):
|
155
301
|
"""Generate file difference records for a comparison"""
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
""",
|
302
|
+
self._exec(
|
303
|
+
"""
|
304
|
+
INSERT INTO file_differences
|
305
|
+
(comparison_id, file_path, difference_type, source_image_id, target_image_id, old_size, new_size, size_change)
|
306
|
+
SELECT
|
307
|
+
? as comparison_id,
|
308
|
+
f1.file_path,
|
309
|
+
CASE
|
310
|
+
WHEN f2.file_path IS NULL THEN 'only_in_first'
|
311
|
+
WHEN f1.file_size != f2.file_size THEN 'changed'
|
312
|
+
ELSE 'common'
|
313
|
+
END as difference_type,
|
314
|
+
f1.image_id as source_image_id,
|
315
|
+
f2.image_id as target_image_id,
|
316
|
+
f1.file_size as old_size,
|
317
|
+
f2.file_size as new_size,
|
318
|
+
COALESCE(f2.file_size - f1.file_size, -f1.file_size) as size_change
|
319
|
+
FROM files f1
|
320
|
+
JOIN comparison_images ci1 ON f1.image_id = ci1.image_id
|
321
|
+
LEFT JOIN files f2 ON f1.file_path = f2.file_path
|
322
|
+
AND f2.image_id IN (
|
323
|
+
SELECT ci2.image_id FROM comparison_images ci2
|
324
|
+
WHERE ci2.comparison_id = ? AND ci2.image_id != f1.image_id
|
325
|
+
)
|
326
|
+
WHERE ci1.comparison_id = ?
|
327
|
+
""",
|
328
|
+
(comparison_id, comparison_id, comparison_id),
|
329
|
+
)
|
182
330
|
|
183
331
|
def get_comparison_summary(self, comparison_id: int) -> Dict:
|
184
332
|
"""Get summary statistics for a comparison"""
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
333
|
+
# Get basic info
|
334
|
+
basic_rows = self._exec(
|
335
|
+
"""
|
336
|
+
SELECT c.name, c.description, c.created_at,
|
337
|
+
GROUP_CONCAT(i.name, ', ') as images
|
338
|
+
FROM comparisons c
|
339
|
+
JOIN comparison_images ci ON c.id = ci.comparison_id
|
340
|
+
JOIN images i ON ci.image_id = i.id
|
341
|
+
WHERE c.id = ?
|
342
|
+
GROUP BY c.id
|
343
|
+
""",
|
344
|
+
(comparison_id,),
|
345
|
+
).rows
|
346
|
+
|
347
|
+
if not basic_rows:
|
348
|
+
return {}
|
349
|
+
|
350
|
+
basic_info = basic_rows[0]
|
351
|
+
|
352
|
+
# Get difference counts
|
353
|
+
diff_rows = self._exec(
|
354
|
+
"""
|
355
|
+
SELECT difference_type, COUNT(*) as count
|
356
|
+
FROM file_differences
|
357
|
+
WHERE comparison_id = ?
|
358
|
+
GROUP BY difference_type
|
359
|
+
""",
|
360
|
+
(comparison_id,),
|
361
|
+
).rows
|
362
|
+
|
363
|
+
diff_counts = {k: v for (k, v) in diff_rows}
|
364
|
+
|
365
|
+
return {
|
366
|
+
'name': basic_info[0],
|
367
|
+
'description': basic_info[1],
|
368
|
+
'created_at': basic_info[2],
|
369
|
+
'images': basic_info[3].split(', '),
|
370
|
+
'differences': diff_counts,
|
371
|
+
'total_differences': sum(diff_counts.values()) if diff_counts else 0,
|
372
|
+
}
|
221
373
|
|
222
374
|
def query_unique_files(self, comparison_id: int) -> List[Tuple]:
|
223
375
|
"""Get files unique to each image in comparison"""
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
376
|
+
rows = self._exec(
|
377
|
+
"""
|
378
|
+
SELECT i.name as image_name, f.file_path, f.file_size
|
379
|
+
FROM unique_files uf
|
380
|
+
JOIN comparisons c ON uf.comparison_name = c.name
|
381
|
+
JOIN images i ON uf.image_name = i.name
|
382
|
+
JOIN files f ON i.id = f.image_id AND uf.file_path = f.file_path
|
383
|
+
WHERE c.id = ?
|
384
|
+
ORDER BY i.name, f.file_size DESC
|
385
|
+
""",
|
386
|
+
(comparison_id,),
|
387
|
+
).rows
|
388
|
+
return rows
|
236
389
|
|
237
390
|
|
238
391
|
def print_comparison_summary(db: DockerImageDB, comparison_id: int):
|
@@ -265,67 +418,63 @@ def print_comparison_summary(db: DockerImageDB, comparison_id: int):
|
|
265
418
|
|
266
419
|
def list_comparisons(db: DockerImageDB):
|
267
420
|
"""List all comparisons in the database"""
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
img_names = img_names[:32] + "..."
|
297
|
-
print(f"{comp_id:<4} {name[:24]:<25} {img_count:<8} {created[:19]:<20} {img_names}")
|
421
|
+
rows = db._exec(
|
422
|
+
"""
|
423
|
+
SELECT
|
424
|
+
c.id,
|
425
|
+
c.name,
|
426
|
+
c.created_at,
|
427
|
+
COUNT(DISTINCT ci.image_id) as image_count,
|
428
|
+
GROUP_CONCAT(i.name, ', ') as images
|
429
|
+
FROM comparisons c
|
430
|
+
JOIN comparison_images ci ON c.id = ci.comparison_id
|
431
|
+
JOIN images i ON ci.image_id = i.id
|
432
|
+
GROUP BY c.id
|
433
|
+
ORDER BY c.created_at DESC
|
434
|
+
""",
|
435
|
+
).rows
|
436
|
+
|
437
|
+
if not rows:
|
438
|
+
print("No comparisons found in database.")
|
439
|
+
return
|
440
|
+
|
441
|
+
print(f"\n{'ID':<4} {'Name':<25} {'Images':<8} {'Date':<20} {'Image Names'}")
|
442
|
+
print("-" * 80)
|
443
|
+
|
444
|
+
for comp_id, name, created, img_count, img_names in rows:
|
445
|
+
# Truncate long image names
|
446
|
+
if img_names and len(img_names) > 35:
|
447
|
+
img_names = img_names[:32] + "..."
|
448
|
+
print(f"{comp_id:<4} {name[:24]:<25} {img_count:<8} {created[:19]:<20} {img_names}")
|
298
449
|
|
299
450
|
|
300
451
|
def list_images(db: DockerImageDB):
|
301
452
|
"""List all images in the database"""
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
size_mb = total_size / (1024 * 1024) if total_size else 0
|
328
|
-
print(f"{img_id:<4} {name[:29]:<30} {file_count:<8} {size_mb:<12.2f} {scanned[:19] if scanned else 'Never'}")
|
453
|
+
rows = db._exec(
|
454
|
+
"""
|
455
|
+
SELECT
|
456
|
+
i.id,
|
457
|
+
i.name,
|
458
|
+
i.scanned_at,
|
459
|
+
COUNT(f.id) as file_count,
|
460
|
+
COALESCE(SUM(f.file_size), 0) as total_size
|
461
|
+
FROM images i
|
462
|
+
LEFT JOIN files f ON i.id = f.image_id
|
463
|
+
GROUP BY i.id
|
464
|
+
ORDER BY i.scanned_at DESC
|
465
|
+
""",
|
466
|
+
).rows
|
467
|
+
|
468
|
+
if not rows:
|
469
|
+
print("No images found in database.")
|
470
|
+
return
|
471
|
+
|
472
|
+
print(f"\n{'ID':<4} {'Image Name':<30} {'Files':<8} {'Size (MB)':<12} {'Scanned'}")
|
473
|
+
print("-" * 80)
|
474
|
+
|
475
|
+
for img_id, name, scanned, file_count, total_size in rows:
|
476
|
+
size_mb = total_size / (1024 * 1024) if total_size else 0
|
477
|
+
print(f"{img_id:<4} {name[:29]:<30} {file_count:<8} {size_mb:<12.2f} {scanned[:19] if scanned else 'Never'}")
|
329
478
|
|
330
479
|
|
331
480
|
def show_unique_files(db: DockerImageDB, comparison_id: int, limit: int = 20):
|
@@ -349,10 +498,10 @@ def show_unique_files(db: DockerImageDB, comparison_id: int, limit: int = 20):
|
|
349
498
|
|
350
499
|
def _cmd_scan(db: DockerImageDB, args):
|
351
500
|
for image in args.images:
|
352
|
-
db.scan_image(image)
|
501
|
+
db.scan_image(image, force=args.force)
|
353
502
|
|
354
503
|
def _cmd_compare(db: DockerImageDB, args):
|
355
|
-
comparison_id = db.compare_images(args.images, args.name)
|
504
|
+
comparison_id = db.compare_images(args.images, args.name, force=args.force)
|
356
505
|
print_comparison_summary(db, comparison_id)
|
357
506
|
|
358
507
|
def _cmd_list_images(db: DockerImageDB, args):
|
@@ -377,11 +526,13 @@ def main():
|
|
377
526
|
|
378
527
|
p_scan = sub.add_parser("scan", help="Scan one or more images and store file listings")
|
379
528
|
p_scan.add_argument("images", nargs="+", help="Docker image names (e.g., ubuntu:22.04)")
|
529
|
+
p_scan.add_argument("--force", action="store_true", help="Re-scan even if image exists in DB (overrides skip)")
|
380
530
|
p_scan.set_defaults(func=_cmd_scan)
|
381
531
|
|
382
532
|
p_compare = sub.add_parser("compare", help="Compare images and store results")
|
383
533
|
p_compare.add_argument("images", nargs="+", help="Docker image names to compare")
|
384
534
|
p_compare.add_argument("--name", help="Optional comparison name")
|
535
|
+
p_compare.add_argument("--force", action="store_true", help="Re-scan images even if they exist in DB")
|
385
536
|
p_compare.set_defaults(func=_cmd_compare)
|
386
537
|
|
387
538
|
p_list = sub.add_parser("list", help="List images or comparisons")
|
@@ -404,8 +555,11 @@ def main():
|
|
404
555
|
|
405
556
|
args = parser.parse_args()
|
406
557
|
db = DockerImageDB()
|
407
|
-
|
558
|
+
try:
|
559
|
+
args.func(db, args)
|
560
|
+
finally:
|
561
|
+
db.close()
|
408
562
|
|
409
563
|
|
410
564
|
if __name__ == "__main__":
|
411
|
-
main()
|
565
|
+
main()
|
@@ -1,10 +0,0 @@
|
|
1
|
-
docker_diff-0.0.6.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
|
2
|
-
docker_diff_pkg/__init__.py,sha256=aG5WUO74Z5ax04A5iwh6thCpHcFXBsdH5v-ph-uUJuI,321
|
3
|
-
docker_diff_pkg/_version.py,sha256=7MyqQ3iPP2mJruPfRYGCNCq1z7_Nk7c-eyYecYITxsY,704
|
4
|
-
docker_diff_pkg/cli.py,sha256=iXC5GR1jCzhmJpFCSJHSujL3vdAkXQOpeOtBJYGQy_A,15983
|
5
|
-
docker_diff_pkg/schema.sql,sha256=xI3kOohbUEzwFhmIGQs2_abN_tJn1BBdAya7tVbw6n8,5477
|
6
|
-
docker_diff-0.0.6.dist-info/METADATA,sha256=H4wzPbx2ob8_zB3kHB41ZkxXnfPI7ouJxfkL-HDWKcQ,1654
|
7
|
-
docker_diff-0.0.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
-
docker_diff-0.0.6.dist-info/entry_points.txt,sha256=nEDTWZsgB5fRJgeXHJbXJyaHKMmKyN9Qypf3X78tosw,57
|
9
|
-
docker_diff-0.0.6.dist-info/top_level.txt,sha256=GB5Qrc3AH1pk0543cQz9SRNr5Fatrj-c0Cdp3rCvTM8,16
|
10
|
-
docker_diff-0.0.6.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|