docker-diff 0.0.1__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.
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: docker-diff
3
+ Version: 0.0.1
4
+ Summary: Docker Image Comparison Database Manager
5
+ License: This is free and unencumbered software released into the public domain.
6
+
7
+ Anyone is free to copy, modify, publish, use, compile, sell, or
8
+ distribute this software, either in source code form or as a compiled
9
+ binary, for any purpose, commercial or non-commercial, and by any
10
+ means.
11
+
12
+ In jurisdictions that recognize copyright laws, the author or authors
13
+ of this software dedicate any and all copyright interest in the
14
+ software to the public domain. We make this dedication for the benefit
15
+ of the public at large and to the detriment of our heirs and
16
+ successors. We intend this dedication to be an overt act of
17
+ relinquishment in perpetuity of all present and future rights to this
18
+ software under copyright law.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
22
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
23
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
24
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
25
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
26
+ OTHER DEALINGS IN THE SOFTWARE.
27
+
28
+ For more information, please refer to <https://unlicense.org>
29
+
30
+ Project-URL: Repository, https://github.com/eapolinario/docker-diff
31
+ Requires-Python: >=3.10
32
+ License-File: LICENSE
33
+ Dynamic: license-file
@@ -0,0 +1,7 @@
1
+ docker_diff.py,sha256=_Jt20fhQqZQGac-PacGyV9IU10FXNoCrc6lqlZe4Gps,15516
2
+ docker_diff-0.0.1.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
3
+ docker_diff-0.0.1.dist-info/METADATA,sha256=XnO5IBtAKuZq0xQ8C45jYB445OD91_xdzrTnsfh0Ktc,1654
4
+ docker_diff-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ docker_diff-0.0.1.dist-info/entry_points.txt,sha256=Hknxh2UZBgBH5Lwe55BK5Gh813R1qHZSPIYQ9dzKQXk,49
6
+ docker_diff-0.0.1.dist-info/top_level.txt,sha256=HSDBG4y8znoDTP5jcUCH2gZZ-qiZW0_6AsIeXry71Ew,12
7
+ docker_diff-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docker-diff = docker_diff:main
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
@@ -0,0 +1 @@
1
+ docker_diff
docker_diff.py ADDED
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Docker Image Comparison Database Manager
4
+ Provides functions to store and query Docker image file comparison results in SQLite
5
+ """
6
+
7
+ import sqlite3
8
+ import json
9
+ import subprocess
10
+ import sys
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import List, Dict, Optional, Tuple
14
+
15
+ class DockerImageDB:
16
+ def __init__(self, db_path: str = "docker_images.db"):
17
+ self.db_path = db_path
18
+ self.init_database()
19
+
20
+ def init_database(self):
21
+ """Initialize the database with schema"""
22
+ with sqlite3.connect(self.db_path) as conn:
23
+ # Read and execute schema
24
+ schema_path = Path(__file__).parent / "schema.sql"
25
+ with open(schema_path) as f:
26
+ conn.executescript(f.read())
27
+
28
+ def add_image(self, name: str, digest: str = None, size_bytes: int = None) -> int:
29
+ """Add an image to the database, return image_id"""
30
+ with sqlite3.connect(self.db_path) as conn:
31
+ cursor = conn.cursor()
32
+ cursor.execute("""
33
+ INSERT OR IGNORE INTO images (name, digest, size_bytes)
34
+ VALUES (?, ?, ?)
35
+ """, (name, digest, size_bytes))
36
+
37
+ # Get the image ID
38
+ cursor.execute("SELECT id FROM images WHERE name = ?", (name,))
39
+ return cursor.fetchone()[0]
40
+
41
+ def add_files_for_image(self, image_id: int, files: List[Dict]):
42
+ """Add file listings for an image"""
43
+ with sqlite3.connect(self.db_path) as conn:
44
+ cursor = conn.cursor()
45
+
46
+ # Clear existing files for this image
47
+ cursor.execute("DELETE FROM files WHERE image_id = ?", (image_id,))
48
+
49
+ # Insert new files
50
+ file_data = []
51
+ for file_info in files:
52
+ file_data.append((
53
+ image_id,
54
+ file_info['path'],
55
+ file_info.get('size', 0),
56
+ file_info.get('mode'),
57
+ file_info.get('mtime'),
58
+ file_info.get('type', 'file'),
59
+ file_info.get('checksum')
60
+ ))
61
+
62
+ cursor.executemany("""
63
+ INSERT INTO files
64
+ (image_id, file_path, file_size, file_mode, modified_time, file_type, checksum)
65
+ VALUES (?, ?, ?, ?, ?, ?, ?)
66
+ """, file_data)
67
+
68
+ def scan_image(self, image_name: str) -> int:
69
+ """Scan a Docker image and store its files"""
70
+ print(f"Scanning {image_name}...")
71
+
72
+ # Add image to database
73
+ image_id = self.add_image(image_name)
74
+
75
+ # Get file listing using docker run
76
+ try:
77
+ result = subprocess.run([
78
+ 'docker', 'run', '--rm', image_name,
79
+ 'find', '/', '-type', 'f', '-exec', 'stat', '-c', '%n|%s|%Y', '{}', ';'
80
+ ], capture_output=True, text=True)
81
+
82
+ files = []
83
+ for line in result.stdout.strip().split('\n'):
84
+ if '|' in line:
85
+ parts = line.split('|')
86
+ if len(parts) >= 3:
87
+ files.append({
88
+ 'path': parts[0],
89
+ 'size': int(parts[1]) if parts[1].isdigit() else 0,
90
+ 'mtime': int(parts[2]) if parts[2].isdigit() else None
91
+ })
92
+
93
+ self.add_files_for_image(image_id, files)
94
+ print(f" Stored {len(files)} files for {image_name}")
95
+ return image_id
96
+
97
+ except subprocess.SubprocessError as e:
98
+ print(f"Error scanning {image_name}: {e}")
99
+ return image_id
100
+
101
+ def create_comparison(self, name: str, description: str = None) -> int:
102
+ """Create a new comparison session"""
103
+ with sqlite3.connect(self.db_path) as conn:
104
+ cursor = conn.cursor()
105
+ cursor.execute("""
106
+ INSERT INTO comparisons (name, description)
107
+ VALUES (?, ?)
108
+ """, (name, description))
109
+ return cursor.lastrowid
110
+
111
+ def add_images_to_comparison(self, comparison_id: int, image_ids: List[int]):
112
+ """Add images to a comparison"""
113
+ with sqlite3.connect(self.db_path) as conn:
114
+ cursor = conn.cursor()
115
+ for image_id in image_ids:
116
+ cursor.execute("""
117
+ INSERT OR IGNORE INTO comparison_images (comparison_id, image_id)
118
+ VALUES (?, ?)
119
+ """, (comparison_id, image_id))
120
+
121
+ def compare_images(self, image_names: List[str], comparison_name: str = None) -> int:
122
+ """Compare multiple images and store results"""
123
+ if not comparison_name:
124
+ comparison_name = f"Comparison_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
125
+
126
+ # Scan all images
127
+ image_ids = []
128
+ for image_name in image_names:
129
+ image_id = self.scan_image(image_name)
130
+ image_ids.append(image_id)
131
+
132
+ # Create comparison
133
+ comparison_id = self.create_comparison(
134
+ comparison_name,
135
+ f"Comparing: {', '.join(image_names)}"
136
+ )
137
+
138
+ # Add images to comparison
139
+ self.add_images_to_comparison(comparison_id, image_ids)
140
+
141
+ # Generate file differences
142
+ self._generate_file_differences(comparison_id)
143
+
144
+ return comparison_id
145
+
146
+ def _generate_file_differences(self, comparison_id: int):
147
+ """Generate file difference records for a comparison"""
148
+ with sqlite3.connect(self.db_path) as conn:
149
+ conn.execute("""
150
+ INSERT INTO file_differences
151
+ (comparison_id, file_path, difference_type, source_image_id, target_image_id, old_size, new_size, size_change)
152
+ SELECT
153
+ ? as comparison_id,
154
+ f1.file_path,
155
+ CASE
156
+ WHEN f2.file_path IS NULL THEN 'only_in_first'
157
+ WHEN f1.file_size != f2.file_size THEN 'changed'
158
+ ELSE 'common'
159
+ END as difference_type,
160
+ f1.image_id as source_image_id,
161
+ f2.image_id as target_image_id,
162
+ f1.file_size as old_size,
163
+ f2.file_size as new_size,
164
+ COALESCE(f2.file_size - f1.file_size, -f1.file_size) as size_change
165
+ FROM files f1
166
+ JOIN comparison_images ci1 ON f1.image_id = ci1.image_id
167
+ LEFT JOIN files f2 ON f1.file_path = f2.file_path
168
+ AND f2.image_id IN (
169
+ SELECT ci2.image_id FROM comparison_images ci2
170
+ WHERE ci2.comparison_id = ? AND ci2.image_id != f1.image_id
171
+ )
172
+ WHERE ci1.comparison_id = ?
173
+ """, (comparison_id, comparison_id, comparison_id))
174
+
175
+ def get_comparison_summary(self, comparison_id: int) -> Dict:
176
+ """Get summary statistics for a comparison"""
177
+ with sqlite3.connect(self.db_path) as conn:
178
+ cursor = conn.cursor()
179
+
180
+ # Get basic info
181
+ cursor.execute("""
182
+ SELECT c.name, c.description, c.created_at,
183
+ GROUP_CONCAT(i.name, ', ') as images
184
+ FROM comparisons c
185
+ JOIN comparison_images ci ON c.id = ci.comparison_id
186
+ JOIN images i ON ci.image_id = i.id
187
+ WHERE c.id = ?
188
+ GROUP BY c.id
189
+ """, (comparison_id,))
190
+
191
+ basic_info = cursor.fetchone()
192
+ if not basic_info:
193
+ return {}
194
+
195
+ # Get difference counts
196
+ cursor.execute("""
197
+ SELECT difference_type, COUNT(*) as count
198
+ FROM file_differences
199
+ WHERE comparison_id = ?
200
+ GROUP BY difference_type
201
+ """, (comparison_id,))
202
+
203
+ diff_counts = dict(cursor.fetchall())
204
+
205
+ return {
206
+ 'name': basic_info[0],
207
+ 'description': basic_info[1],
208
+ 'created_at': basic_info[2],
209
+ 'images': basic_info[3].split(', '),
210
+ 'differences': diff_counts,
211
+ 'total_differences': sum(diff_counts.values())
212
+ }
213
+
214
+ def query_unique_files(self, comparison_id: int) -> List[Tuple]:
215
+ """Get files unique to each image in comparison"""
216
+ with sqlite3.connect(self.db_path) as conn:
217
+ cursor = conn.cursor()
218
+ cursor.execute("""
219
+ SELECT i.name as image_name, f.file_path, f.file_size
220
+ FROM unique_files uf
221
+ JOIN comparisons c ON uf.comparison_name = c.name
222
+ JOIN images i ON uf.image_name = i.name
223
+ JOIN files f ON i.id = f.image_id AND uf.file_path = f.file_path
224
+ WHERE c.id = ?
225
+ ORDER BY i.name, f.file_size DESC
226
+ """, (comparison_id,))
227
+ return cursor.fetchall()
228
+
229
+
230
+ def print_comparison_summary(db: DockerImageDB, comparison_id: int):
231
+ """Print detailed comparison summary"""
232
+ summary = db.get_comparison_summary(comparison_id)
233
+ if not summary:
234
+ print(f"No comparison found with ID {comparison_id}")
235
+ return
236
+
237
+ print(f"\n{'='*60}")
238
+ print(f"COMPARISON SUMMARY")
239
+ print(f"{'='*60}")
240
+ print(f"Name: {summary['name']}")
241
+ print(f"Description: {summary.get('description', 'N/A')}")
242
+ print(f"Created: {summary['created_at']}")
243
+ print(f"Images: {', '.join(summary['images'])}")
244
+ print(f"\nDifference Summary:")
245
+
246
+ diff_counts = summary.get('differences', {})
247
+ total = summary.get('total_differences', 0)
248
+
249
+ if total == 0:
250
+ print(" No differences found")
251
+ else:
252
+ for diff_type, count in diff_counts.items():
253
+ percentage = (count / total) * 100
254
+ print(f" {diff_type.capitalize()}: {count} ({percentage:.1f}%)")
255
+ print(f" Total: {total}")
256
+
257
+
258
+ def list_comparisons(db: DockerImageDB):
259
+ """List all comparisons in the database"""
260
+ with sqlite3.connect(db.db_path) as conn:
261
+ cursor = conn.cursor()
262
+ cursor.execute("""
263
+ SELECT
264
+ c.id,
265
+ c.name,
266
+ c.created_at,
267
+ COUNT(DISTINCT ci.image_id) as image_count,
268
+ GROUP_CONCAT(i.name, ', ') as images
269
+ FROM comparisons c
270
+ JOIN comparison_images ci ON c.id = ci.comparison_id
271
+ JOIN images i ON ci.image_id = i.id
272
+ GROUP BY c.id
273
+ ORDER BY c.created_at DESC
274
+ """)
275
+
276
+ comparisons = cursor.fetchall()
277
+
278
+ if not comparisons:
279
+ print("No comparisons found in database.")
280
+ return
281
+
282
+ print(f"\n{'ID':<4} {'Name':<25} {'Images':<8} {'Date':<20} {'Image Names'}")
283
+ print("-" * 80)
284
+
285
+ for comp_id, name, created, img_count, img_names in comparisons:
286
+ # Truncate long image names
287
+ if len(img_names) > 35:
288
+ img_names = img_names[:32] + "..."
289
+ print(f"{comp_id:<4} {name[:24]:<25} {img_count:<8} {created[:19]:<20} {img_names}")
290
+
291
+
292
+ def list_images(db: DockerImageDB):
293
+ """List all images in the database"""
294
+ with sqlite3.connect(db.db_path) as conn:
295
+ cursor = conn.cursor()
296
+ cursor.execute("""
297
+ SELECT
298
+ i.id,
299
+ i.name,
300
+ i.scanned_at,
301
+ COUNT(f.id) as file_count,
302
+ COALESCE(SUM(f.file_size), 0) as total_size
303
+ FROM images i
304
+ LEFT JOIN files f ON i.id = f.image_id
305
+ GROUP BY i.id
306
+ ORDER BY i.scanned_at DESC
307
+ """)
308
+
309
+ images = cursor.fetchall()
310
+
311
+ if not images:
312
+ print("No images found in database.")
313
+ return
314
+
315
+ print(f"\n{'ID':<4} {'Image Name':<30} {'Files':<8} {'Size (MB)':<12} {'Scanned'}")
316
+ print("-" * 80)
317
+
318
+ for img_id, name, scanned, file_count, total_size in images:
319
+ size_mb = total_size / (1024 * 1024) if total_size else 0
320
+ print(f"{img_id:<4} {name[:29]:<30} {file_count:<8} {size_mb:<12.2f} {scanned[:19] if scanned else 'Never'}")
321
+
322
+
323
+ def show_unique_files(db: DockerImageDB, comparison_id: int, limit: int = 20):
324
+ """Show files unique to each image in a comparison"""
325
+ unique_files = db.query_unique_files(comparison_id)
326
+
327
+ if not unique_files:
328
+ print(f"No unique files found for comparison {comparison_id}")
329
+ return
330
+
331
+ print(f"\nUnique Files (showing first {limit}):")
332
+ print(f"{'Image':<25} {'Size (KB)':<12} {'File Path'}")
333
+ print("-" * 80)
334
+
335
+ for i, (image_name, file_path, file_size) in enumerate(unique_files[:limit]):
336
+ size_kb = file_size / 1024 if file_size else 0
337
+ print(f"{image_name[:24]:<25} {size_kb:<12.2f} {file_path}")
338
+
339
+
340
+ # ---- CLI entry point ----
341
+
342
+ def _cmd_scan(db: DockerImageDB, args):
343
+ for image in args.images:
344
+ db.scan_image(image)
345
+
346
+ def _cmd_compare(db: DockerImageDB, args):
347
+ comparison_id = db.compare_images(args.images, args.name)
348
+ print_comparison_summary(db, comparison_id)
349
+
350
+ def _cmd_list_images(db: DockerImageDB, args):
351
+ list_images(db)
352
+
353
+ def _cmd_list_comparisons(db: DockerImageDB, args):
354
+ list_comparisons(db)
355
+
356
+ def _cmd_summary(db: DockerImageDB, args):
357
+ print_comparison_summary(db, args.id)
358
+
359
+ def _cmd_unique(db: DockerImageDB, args):
360
+ show_unique_files(db, args.id, args.limit)
361
+
362
+
363
+ def main():
364
+ """docker-diff command line interface"""
365
+ import argparse
366
+
367
+ parser = argparse.ArgumentParser(prog="docker-diff", description="Docker image file comparison and database manager")
368
+ sub = parser.add_subparsers(dest="cmd", required=True)
369
+
370
+ p_scan = sub.add_parser("scan", help="Scan one or more images and store file listings")
371
+ p_scan.add_argument("images", nargs="+", help="Docker image names (e.g., ubuntu:22.04)")
372
+ p_scan.set_defaults(func=_cmd_scan)
373
+
374
+ p_compare = sub.add_parser("compare", help="Compare images and store results")
375
+ p_compare.add_argument("images", nargs="+", help="Docker image names to compare")
376
+ p_compare.add_argument("--name", help="Optional comparison name")
377
+ p_compare.set_defaults(func=_cmd_compare)
378
+
379
+ p_list = sub.add_parser("list", help="List images or comparisons")
380
+ sub_list = p_list.add_subparsers(dest="what", required=True)
381
+
382
+ p_list_images = sub_list.add_parser("images", help="List scanned images")
383
+ p_list_images.set_defaults(func=_cmd_list_images)
384
+
385
+ p_list_comparisons = sub_list.add_parser("comparisons", help="List comparisons")
386
+ p_list_comparisons.set_defaults(func=_cmd_list_comparisons)
387
+
388
+ p_summary = sub.add_parser("summary", help="Show summary for a comparison")
389
+ p_summary.add_argument("id", type=int, help="Comparison ID")
390
+ p_summary.set_defaults(func=_cmd_summary)
391
+
392
+ p_unique = sub.add_parser("unique", help="Show files unique to each image in a comparison")
393
+ p_unique.add_argument("id", type=int, help="Comparison ID")
394
+ p_unique.add_argument("--limit", type=int, default=20, help="Max rows to display")
395
+ p_unique.set_defaults(func=_cmd_unique)
396
+
397
+ args = parser.parse_args()
398
+ db = DockerImageDB()
399
+ args.func(db, args)
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()