mirror-dedupe 0.1.0__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,13 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ __init__.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ __version__ = '0.1.0'
mirror_dedupe/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ cli.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import subprocess
16
+ import atexit
17
+ import signal
18
+ import argparse
19
+ from pathlib import Path
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from collections import defaultdict
22
+
23
+ from .config import load_config
24
+ from .utils import (acquire_lock, release_lock, signal_handler,
25
+ get_disk_usage, format_bytes)
26
+ from .dedupe import expand_distributions
27
+ from .orchestrate import (run_orchestrator_mode, sync_mirrors, collect_files,
28
+ analyse_deduplication, check_existing_files,
29
+ process_files, cleanup_mirrors, print_final_summary)
30
+
31
+
32
+ def main():
33
+ """Main entry point for mirror-dedupe"""
34
+ parser = argparse.ArgumentParser(
35
+ description='Mirror repository with global deduplication',
36
+ formatter_class=argparse.RawDescriptionHelpFormatter
37
+ )
38
+
39
+ parser.add_argument('config_dir', nargs='?', default='/etc/mirror-dedupe',
40
+ help='Path to configuration directory (default: /etc/mirror-dedupe)')
41
+ parser.add_argument('--dry-run', action='store_true',
42
+ help='Show what would be done without actually doing it')
43
+ parser.add_argument('--mirror', type=str,
44
+ help='Process only the specified mirror (by name)')
45
+ parser.add_argument('--dedupe-only', action='store_true',
46
+ help='Only run deduplication phase (skip mirror sync)')
47
+
48
+ args = parser.parse_args()
49
+
50
+ # Determine mode and acquire appropriate lock
51
+ if args.dedupe_only:
52
+ if not acquire_lock('dedupe'):
53
+ sys.exit(1)
54
+ atexit.register(release_lock)
55
+ signal.signal(signal.SIGTERM, signal_handler)
56
+ signal.signal(signal.SIGINT, signal_handler)
57
+ elif args.mirror:
58
+ if not acquire_lock(args.mirror):
59
+ sys.exit(1)
60
+ atexit.register(release_lock)
61
+ signal.signal(signal.SIGTERM, signal_handler)
62
+ signal.signal(signal.SIGINT, signal_handler)
63
+
64
+ # Load configuration
65
+ config_dir = args.config_dir
66
+ config = load_config(config_dir)
67
+ mirrors = config.get('mirrors', [])
68
+
69
+ if not mirrors:
70
+ print("No mirrors defined in configuration")
71
+ sys.exit(1)
72
+
73
+ print(f"\n{'='*60}")
74
+ print(f"Loaded {len(mirrors)} mirror(s) from configuration")
75
+ print(f"{'='*60}")
76
+
77
+ # Orchestrator mode: spawn subprocess for each mirror
78
+ if not args.mirror and not args.dedupe_only:
79
+ run_orchestrator_mode(mirrors, config_dir, args.dry_run)
80
+ # This function exits, so we never reach here
81
+
82
+ # Filter mirrors if --mirror specified
83
+ if args.mirror:
84
+ filtered_mirrors = [m for m in mirrors if m['name'] == args.mirror]
85
+ if not filtered_mirrors:
86
+ print(f"ERROR: Mirror '{args.mirror}' not found in configuration")
87
+ sys.exit(1)
88
+ mirrors = filtered_mirrors
89
+ print(f"\n{'='*60}")
90
+ print(f"SINGLE MIRROR MODE: Processing '{args.mirror}'")
91
+ print(f"{'='*60}")
92
+
93
+ # Skip mirror sync if --dedupe-only
94
+ if args.dedupe_only:
95
+ print(f"\n{'='*60}")
96
+ print("DEDUPE-ONLY MODE: Skipping mirror sync")
97
+ print(f"{'='*60}")
98
+ else:
99
+ sync_mirrors(mirrors, args.dry_run)
100
+
101
+ # Collect all files needed across all mirrors
102
+ global_files = collect_files(mirrors)
103
+
104
+ # Analyse deduplication potential
105
+ hash_to_files, unique_files = analyse_deduplication(global_files)
106
+
107
+ # Check existing files
108
+ check_existing_files(hash_to_files)
109
+
110
+ # Get initial disk usage
111
+ print(f"\n{'='*60}")
112
+ print("Initial disk usage")
113
+ print(f"{'='*60}")
114
+ first_dest = mirrors[0]['dest']
115
+ total, initial_used, free = get_disk_usage(first_dest)
116
+ print(f"Overall mirror filesystem: Used: {format_bytes(initial_used)}, Free: {format_bytes(free)}")
117
+
118
+ # In single-mirror mode, note that cross-mirror deduplication will be handled separately
119
+ if args.mirror:
120
+ print(f"\n{'='*60}")
121
+ print(f"Single mirror mode: Deduplication will be handled separately")
122
+ print(f"{'='*60}")
123
+
124
+ # Process files (download and hardlink)
125
+ downloaded, hardlinked, skipped = process_files(hash_to_files, unique_files, config, args.dry_run)
126
+
127
+ # In single-mirror mode, exit after downloading (skip cleanup and cross-mirror dedup)
128
+ if args.mirror:
129
+ print(f"\nMirror '{args.mirror}' sync completed successfully!")
130
+ sys.exit(0)
131
+
132
+ # Cleanup mirrors
133
+ cleanup_mirrors(mirrors, global_files, args.dry_run)
134
+
135
+ # Print final summary
136
+ print_final_summary(mirrors, downloaded, hardlinked, skipped, initial_used)
137
+
138
+
139
+ if __name__ == '__main__':
140
+ main()
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ config.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ import os
14
+ import sys
15
+ import yaml
16
+ from pathlib import Path
17
+
18
+
19
+ def load_config(config_dir: str) -> dict:
20
+ """Load mirror configuration from config directory
21
+
22
+ Loads main config and all repo definitions from repos.d/
23
+ """
24
+ try:
25
+ # Load main config
26
+ main_config_path = Path(config_dir) / 'mirror-dedupe.conf'
27
+ with open(main_config_path, 'r') as f:
28
+ config = yaml.safe_load(f) or {}
29
+
30
+ # Load repo definitions from repos.d/
31
+ repos_dir = Path(config_dir) / 'repos.d'
32
+ mirrors = []
33
+
34
+ if repos_dir.exists() and repos_dir.is_dir():
35
+ for repo_file in sorted(repos_dir.glob('*.conf')):
36
+ try:
37
+ with open(repo_file, 'r') as f:
38
+ mirror = yaml.safe_load(f)
39
+ if mirror:
40
+ # Prepend repo_root to dest if it's relative
41
+ repo_root = config.get('repo_root', '/srv/mirror/repos')
42
+ dest = mirror.get('dest', '')
43
+ if not os.path.isabs(dest):
44
+ mirror['dest'] = os.path.join(repo_root, dest)
45
+ mirrors.append(mirror)
46
+ except Exception as e:
47
+ print(f"Warning: Failed to load {repo_file}: {e}")
48
+
49
+ # Add mirrors to config
50
+ config['mirrors'] = mirrors
51
+
52
+ return config
53
+ except Exception as e:
54
+ print(f"Error loading config: {e}")
55
+ sys.exit(1)
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dedupe.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ import os
14
+ from typing import Set, Tuple
15
+
16
+
17
+ def hardlink_file(source: str, dest: str, expected_hash: str = None) -> bool:
18
+ """Create hardlink from source to dest"""
19
+ dest_dir = os.path.dirname(dest)
20
+ os.makedirs(dest_dir, exist_ok=True)
21
+
22
+ try:
23
+ # Check if source and dest are already the same file (hardlinked)
24
+ if os.path.exists(dest) and os.path.samefile(source, dest):
25
+ return True # Already hardlinked, nothing to do
26
+
27
+ # Remove dest if it exists (whether it's a file, different hardlink, or corrupted)
28
+ if os.path.exists(dest):
29
+ os.remove(dest)
30
+
31
+ # Create hardlink
32
+ os.link(source, dest)
33
+ return True
34
+ except Exception as e:
35
+ print(f" Error hardlinking {source} -> {dest}: {e}")
36
+ return False
37
+
38
+
39
+ def expand_distributions(distributions: list) -> list:
40
+ """Expand distribution names to include variants"""
41
+ expanded = []
42
+ for dist in distributions:
43
+ expanded.append(dist)
44
+ if '-' not in dist:
45
+ expanded.extend([
46
+ f"{dist}-updates",
47
+ f"{dist}-security",
48
+ f"{dist}-backports",
49
+ f"{dist}-proposed"
50
+ ])
51
+ return expanded
52
+
53
+
54
+ def cleanup_pool(dest_base: str, expected_files: Set[str], dry_run: bool = False) -> Tuple[int, int]:
55
+ """Remove files from pool/ that aren't in the expected list"""
56
+ print(f"\n{'='*60}")
57
+ print("Cleaning up pool directory")
58
+ print(f"{'='*60}")
59
+
60
+ pool_path = os.path.join(dest_base, 'pool')
61
+ if not os.path.exists(pool_path):
62
+ print(" No pool directory found")
63
+ return (0, 0)
64
+
65
+ removed_files = 0
66
+ removed_dirs = 0
67
+
68
+ # Walk through pool/ and find files to remove
69
+ for root, dirs, files in os.walk(pool_path, topdown=False):
70
+ for filename in files:
71
+ full_path = os.path.join(root, filename)
72
+ # Get relative path from dest_base
73
+ rel_path = os.path.relpath(full_path, dest_base)
74
+
75
+ if rel_path not in expected_files:
76
+ if dry_run:
77
+ print(f" Would remove: {rel_path}")
78
+ removed_files += 1
79
+ else:
80
+ try:
81
+ os.remove(full_path)
82
+ removed_files += 1
83
+
84
+ if removed_files % 100 == 0:
85
+ print(f" Removed {removed_files} files...")
86
+ except Exception as e:
87
+ print(f" Error removing {rel_path}: {e}")
88
+
89
+ # Remove empty directories
90
+ for dirname in dirs:
91
+ dir_path = os.path.join(root, dirname)
92
+ try:
93
+ if not os.listdir(dir_path): # Directory is empty
94
+ if dry_run:
95
+ removed_dirs += 1
96
+ else:
97
+ os.rmdir(dir_path)
98
+ removed_dirs += 1
99
+ except:
100
+ pass # Directory not empty or other issue
101
+
102
+ if dry_run:
103
+ print(f"\nWould remove: {removed_files} files, {removed_dirs} directories")
104
+ else:
105
+ print(f"\nRemoved: {removed_files} files, {removed_dirs} directories")
106
+
107
+ return (removed_files, removed_dirs)
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ download.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ import os
14
+ import subprocess
15
+ import hashlib
16
+ from .utils import download_lock, active_downloads
17
+
18
+ # Check if sha256sum is available at startup
19
+ USE_SHA256SUM = False
20
+ try:
21
+ result = subprocess.run(['sha256sum', '--version'], capture_output=True)
22
+ USE_SHA256SUM = result.returncode == 0
23
+ except:
24
+ pass
25
+
26
+
27
+ def download_with_curl(url: str, dest_path: str, timeout: int = 300, progress_info: str = "") -> bool:
28
+ """Download file with curl, supports resuming partial downloads"""
29
+ from . import utils
30
+
31
+ dest_dir = os.path.dirname(dest_path)
32
+ os.makedirs(dest_dir, exist_ok=True)
33
+
34
+ # Show what we're downloading
35
+ filename = os.path.basename(dest_path)
36
+ with utils.download_lock:
37
+ utils.active_downloads += 1
38
+ current_active = utils.active_downloads
39
+ print(f" ⬇ Downloading: {filename} ({current_active} active){progress_info}", flush=True)
40
+
41
+ try:
42
+ # -C - enables automatic resume of partial downloads
43
+ cmd = ['curl', '-f', '-L', '-C', '-', '--max-time', str(timeout), '-o', dest_path, url]
44
+ result = subprocess.run(cmd, capture_output=True)
45
+
46
+ with utils.download_lock:
47
+ utils.active_downloads -= 1
48
+ remaining = utils.active_downloads
49
+
50
+ if result.returncode == 0:
51
+ print(f" [OK] Completed: {filename} ({remaining} remaining)", flush=True)
52
+ else:
53
+ print(f" [FAIL] Failed: {filename} ({remaining} remaining)", flush=True)
54
+
55
+ return result.returncode == 0
56
+ except Exception as e:
57
+ with utils.download_lock:
58
+ utils.active_downloads -= 1
59
+ remaining = utils.active_downloads
60
+ print(f" [ERROR] Error: {filename} ({remaining} remaining) - {e}", flush=True)
61
+ return False
62
+
63
+
64
+ def verify_sha256(file_path: str, expected_hash: str, buffer_size: int = 1048576) -> bool:
65
+ """Verify file SHA256 hash using sha256sum or Python hashlib"""
66
+ if USE_SHA256SUM:
67
+ # Use fast sha256sum command
68
+ try:
69
+ result = subprocess.run(['sha256sum', file_path],
70
+ capture_output=True, text=True)
71
+ if result.returncode == 0:
72
+ actual_hash = result.stdout.split()[0]
73
+ return actual_hash == expected_hash
74
+ return False
75
+ except:
76
+ return False
77
+ else:
78
+ # Use Python hashlib
79
+ try:
80
+ sha256 = hashlib.sha256()
81
+ with open(file_path, 'rb') as f:
82
+ for chunk in iter(lambda: f.read(buffer_size), b''):
83
+ sha256.update(chunk)
84
+ return sha256.hexdigest() == expected_hash
85
+ except:
86
+ return False
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ indices.py
4
+
5
+ Ubuntu mirror synchronisation with global deduplication
6
+
7
+ Copyright (c) 2025 Tim Hosking
8
+ Email: tim@mungerware.com
9
+ Website: https://github.com/munger
10
+ Licence: MIT
11
+ """
12
+
13
+ import os
14
+ import gzip
15
+ from typing import Dict, Set
16
+
17
+
18
+ def read_gzipped_file(filepath: str) -> str:
19
+ """Read and decompress a gzipped file from local filesystem"""
20
+ try:
21
+ with gzip.open(filepath, 'rt', encoding='utf-8') as f:
22
+ return f.read()
23
+ except Exception as e:
24
+ print(f" Error reading {filepath}: {e}")
25
+ return None
26
+
27
+
28
+ def parse_packages_file(content: str) -> Dict[str, Dict[str, str]]:
29
+ """
30
+ Parse Packages file and extract package info.
31
+ Returns: {filename: {'sha256': hash, 'size': size, 'package': name}}
32
+ """
33
+ packages = {}
34
+ current_package = {}
35
+
36
+ for line in content.split('\n'):
37
+ line = line.rstrip()
38
+
39
+ if not line:
40
+ if 'filename' in current_package and 'sha256' in current_package:
41
+ filename = current_package['filename']
42
+ packages[filename] = {
43
+ 'sha256': current_package['sha256'],
44
+ 'size': current_package.get('size', '0'),
45
+ 'package': current_package.get('package', 'unknown')
46
+ }
47
+ current_package = {}
48
+ continue
49
+
50
+ if ':' in line and not line.startswith(' '):
51
+ key, value = line.split(':', 1)
52
+ key = key.strip().lower()
53
+ value = value.strip()
54
+
55
+ if key == 'package':
56
+ current_package['package'] = value
57
+ elif key == 'filename':
58
+ current_package['filename'] = value
59
+ elif key == 'sha256':
60
+ current_package['sha256'] = value
61
+ elif key == 'size':
62
+ current_package['size'] = value
63
+
64
+ return packages
65
+
66
+
67
+ def parse_sources_file(content: str) -> Dict[str, Dict[str, str]]:
68
+ """
69
+ Parse Sources file and extract source package info.
70
+ Returns: {filename: {'sha256': hash, 'size': size, 'package': name}}
71
+ """
72
+ sources = {}
73
+ current_package = {}
74
+ current_files = []
75
+
76
+ for line in content.split('\n'):
77
+ line = line.rstrip()
78
+
79
+ if not line:
80
+ if 'directory' in current_package and current_files:
81
+ directory = current_package['directory']
82
+ for filename, size, sha256 in current_files:
83
+ full_path = f"{directory}/{filename}"
84
+ sources[full_path] = {
85
+ 'sha256': sha256,
86
+ 'size': size,
87
+ 'package': current_package.get('package', 'unknown')
88
+ }
89
+ current_package = {}
90
+ current_files = []
91
+ continue
92
+
93
+ if ':' in line and not line.startswith(' '):
94
+ key, value = line.split(':', 1)
95
+ key = key.strip().lower()
96
+ value = value.strip()
97
+
98
+ if key == 'package':
99
+ current_package['package'] = value
100
+ current_package['in_checksums'] = False
101
+ elif key == 'directory':
102
+ current_package['directory'] = value
103
+ current_package['in_checksums'] = False
104
+ elif key == 'checksums-sha256':
105
+ current_package['in_checksums'] = True
106
+ else:
107
+ current_package['in_checksums'] = False
108
+ elif line.startswith(' ') and current_package.get('in_checksums'):
109
+ parts = line.strip().split()
110
+ if len(parts) >= 3:
111
+ sha256 = parts[0]
112
+ size = parts[1]
113
+ filename = ' '.join(parts[2:])
114
+ if filename and not filename.endswith(')') and '(' not in filename:
115
+ current_files.append((filename, size, sha256))
116
+
117
+ if 'directory' in current_package and current_files:
118
+ directory = current_package['directory']
119
+ for filename, size, sha256 in current_files:
120
+ full_path = f"{directory}/{filename}"
121
+ sources[full_path] = {
122
+ 'sha256': sha256,
123
+ 'size': size,
124
+ 'package': current_package.get('package', 'unknown')
125
+ }
126
+
127
+ return sources
128
+
129
+
130
+ def parse_release_file(dest_base: str, distribution: str) -> Set[str]:
131
+ """
132
+ Parse Release file and return set of available index files.
133
+ Returns: set of relative paths like 'main/binary-amd64/Packages.gz'
134
+ """
135
+ release_path = f"{dest_base}/dists/{distribution}/Release"
136
+
137
+ if not os.path.exists(release_path):
138
+ return set()
139
+
140
+ available_files = set()
141
+ in_sha256_section = False
142
+
143
+ try:
144
+ with open(release_path, 'r') as f:
145
+ for line in f:
146
+ line = line.rstrip()
147
+
148
+ # Look for SHA256 section
149
+ if line.startswith('SHA256:'):
150
+ in_sha256_section = True
151
+ continue
152
+ elif line and not line.startswith(' '):
153
+ # New section started
154
+ in_sha256_section = False
155
+
156
+ # Parse file entries in SHA256 section
157
+ if in_sha256_section and line.startswith(' '):
158
+ parts = line.split()
159
+ if len(parts) >= 3:
160
+ # Format: " <hash> <size> <path>"
161
+ file_path = parts[2]
162
+ available_files.add(file_path)
163
+ except Exception as e:
164
+ print(f" Warning: Could not parse Release file: {e}")
165
+ return set()
166
+
167
+ return available_files
168
+
169
+
170
+ def get_packages_index(dest_base: str, distribution: str, component: str, arch: str) -> Dict[str, Dict[str, str]]:
171
+ """Read and parse local Packages.gz for a specific component/arch"""
172
+ packages_path = f"{dest_base}/dists/{distribution}/{component}/binary-{arch}/Packages.gz"
173
+ content = read_gzipped_file(packages_path)
174
+
175
+ if content is None:
176
+ return {}
177
+
178
+ return parse_packages_file(content)
179
+
180
+
181
+ def get_sources_index(dest_base: str, distribution: str, component: str) -> Dict[str, Dict[str, str]]:
182
+ """Read and parse local Sources.gz for a specific component"""
183
+ sources_path = f"{dest_base}/dists/{distribution}/{component}/source/Sources.gz"
184
+ content = read_gzipped_file(sources_path)
185
+
186
+ if content is None:
187
+ return {}
188
+
189
+ return parse_sources_file(content)