patcherly-connector 2.2.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Shambix (Patcherly)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: patcherly-connector
3
+ Version: 2.2.0
4
+ Summary: Patcherly Python connector — monitors logs and applies approved fixes via OAuth.
5
+ Author-email: Patcherly <support@patcherly.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://patcherly.com
8
+ Project-URL: Repository, https://github.com/Patcherly-Official/patcherly-connector-packages
9
+ Project-URL: Documentation, https://help.patcherly.com/connectors/python/
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: httpx>=0.27
14
+ Requires-Dist: python-dotenv>=1.0
15
+ Dynamic: license-file
16
+
17
+ # Patcherly Python connector
18
+
19
+ Monitors application logs and applies approved fixes via OAuth pairing with [Patcherly](https://patcherly.com).
20
+
21
+ ## Recommended install
22
+
23
+ Use the [universal installer](https://help.patcherly.com/getting-started/installing-connector/) (one command, auto-pairs).
24
+
25
+ ## Package install
26
+
27
+ ```bash
28
+ pip install patcherly-connector
29
+ patcherly login
30
+ ```
31
+
32
+ ## Pairing
33
+
34
+ ```bash
35
+ patcherly login
36
+ ```
37
+
38
+ ## Documentation
39
+
40
+ - [Python connector guide](https://help.patcherly.com/connectors/python/)
41
+ - [All connectors (releases & source)](https://github.com/Patcherly-Official/patcherly-connector-packages#readme)
42
+
43
+ ## Support
44
+
45
+ - [Report a bug](https://github.com/Patcherly-Official/patcherly-connector-packages/issues)
46
+
47
+ ## License
48
+
49
+ [MIT](LICENSE)
@@ -0,0 +1,33 @@
1
+ # Patcherly Python connector
2
+
3
+ Monitors application logs and applies approved fixes via OAuth pairing with [Patcherly](https://patcherly.com).
4
+
5
+ ## Recommended install
6
+
7
+ Use the [universal installer](https://help.patcherly.com/getting-started/installing-connector/) (one command, auto-pairs).
8
+
9
+ ## Package install
10
+
11
+ ```bash
12
+ pip install patcherly-connector
13
+ patcherly login
14
+ ```
15
+
16
+ ## Pairing
17
+
18
+ ```bash
19
+ patcherly login
20
+ ```
21
+
22
+ ## Documentation
23
+
24
+ - [Python connector guide](https://help.patcherly.com/connectors/python/)
25
+ - [All connectors (releases & source)](https://github.com/Patcherly-Official/patcherly-connector-packages#readme)
26
+
27
+ ## Support
28
+
29
+ - [Report a bug](https://github.com/Patcherly-Official/patcherly-connector-packages/issues)
30
+
31
+ ## License
32
+
33
+ [MIT](LICENSE)
@@ -0,0 +1,521 @@
1
+ """
2
+ Agent-Side Backup Manager
3
+ Manages versioned backups with checksums, compression, and integrity verification.
4
+ """
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import shutil
10
+ import gzip
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import List, Dict, Optional, Any
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class BackupMetadata:
20
+ """Metadata for a backup operation."""
21
+ def __init__(
22
+ self,
23
+ error_id: str,
24
+ backup_dir: str,
25
+ files: List[str],
26
+ manifest: Dict[str, Any],
27
+ created_at: str,
28
+ verified: bool = True
29
+ ):
30
+ self.error_id = error_id
31
+ self.backup_dir = backup_dir
32
+ self.files = files
33
+ self.manifest = manifest
34
+ self.created_at = created_at
35
+ self.verified = verified
36
+
37
+ def to_dict(self) -> Dict[str, Any]:
38
+ """Convert to dictionary for JSON serialization."""
39
+ return {
40
+ "error_id": self.error_id,
41
+ "backup_dir": self.backup_dir,
42
+ "files": self.files,
43
+ "manifest": self.manifest,
44
+ "created_at": self.created_at,
45
+ "verified": self.verified
46
+ }
47
+
48
+
49
+ class AgentBackupManager:
50
+ """
51
+ Manages backups locally on target environment.
52
+
53
+ Backup structure (default dir: .patcherly_backups or PATCHERLY_BACKUP_ROOT):
54
+ .patcherly_backups/ or custom path
55
+ {error_id}/
56
+ {timestamp}/
57
+ manifest.json
58
+ {file_name}
59
+ ...
60
+ """
61
+
62
+ def __init__(self, backup_root: str | None = None):
63
+ """
64
+ Initialize backup manager.
65
+
66
+ Args:
67
+ backup_root: Root directory for backups. If None, uses:
68
+ - PATCHERLY_BACKUP_ROOT environment variable
69
+ - ../backups/ (outside webroot, default)
70
+ """
71
+ if backup_root is None:
72
+ backup_root = os.getenv('PATCHERLY_BACKUP_ROOT') or '../backups'
73
+
74
+ # Validate and resolve backup root path
75
+ backup_root_path = Path(backup_root)
76
+ if not backup_root_path.is_absolute():
77
+ backup_root_path = backup_root_path.resolve()
78
+
79
+ # Security: Ensure path is absolute and doesn't contain dangerous patterns
80
+ self.backup_root = backup_root_path.resolve()
81
+
82
+ # Additional security: Validate path doesn't contain dangerous patterns
83
+ # Note: After resolve(), '..' is normalized, but we check the original input
84
+ backup_root_str = str(backup_root)
85
+ if '..' in backup_root_str and not self.backup_root.exists():
86
+ # If original had '..' and resolved path doesn't exist, it might be traversal
87
+ logger.warning(f"Backup root path contains '..' and resolved path doesn't exist: {backup_root}")
88
+
89
+ self.backup_root.mkdir(parents=True, exist_ok=True, mode=0o700) # Restrictive permissions
90
+ logger.info(f"Initialized AgentBackupManager with root: {self.backup_root}")
91
+
92
+ # Ensure backup directory is protected from direct web access
93
+ self._ensure_backup_protection()
94
+
95
+ async def create_backup(
96
+ self,
97
+ error_id: str,
98
+ files: List[str],
99
+ compress: bool = True,
100
+ verify: bool = True
101
+ ) -> BackupMetadata:
102
+ """
103
+ Create a versioned backup with checksums.
104
+
105
+ Args:
106
+ error_id: Unique error identifier
107
+ files: List of file paths to backup
108
+ compress: Whether to compress backup files
109
+ verify: Whether to verify backup integrity after creation
110
+
111
+ Returns:
112
+ BackupMetadata object
113
+ """
114
+ timestamp = datetime.now(timezone.utc).isoformat().replace(':', '-')
115
+ backup_dir = self.backup_root / error_id / timestamp
116
+ backup_dir.mkdir(parents=True, exist_ok=True, mode=0o755)
117
+
118
+ logger.info(f"Creating backup in {backup_dir} for {len(files)} file(s)")
119
+
120
+ # Backup files with checksums
121
+ backup_manifest = {}
122
+
123
+ for file_path in files:
124
+ try:
125
+ file_path_obj = Path(file_path)
126
+
127
+ # Security: Validate file path to prevent directory traversal
128
+ if not self._validate_file_path(file_path_obj):
129
+ logger.warning(f"Invalid or unsafe file path, skipping: {file_path}")
130
+ continue
131
+
132
+ # Check if file exists
133
+ if not file_path_obj.exists():
134
+ logger.warning(f"File not found, skipping: {file_path}")
135
+ continue
136
+
137
+ # Read file content
138
+ content = await self._read_file_async(file_path_obj)
139
+
140
+ # Calculate checksum
141
+ checksum = hashlib.sha256(content).hexdigest()
142
+ file_size = len(content)
143
+
144
+ # Determine backup filename (preserve relative path structure)
145
+ # For simplicity, use just the filename, but could preserve path structure
146
+ backup_file_name = file_path_obj.name
147
+
148
+ # If file is in a subdirectory, preserve structure
149
+ if file_path_obj.parent != Path('.'):
150
+ # Create subdirectory structure in backup
151
+ rel_path = file_path_obj.relative_to(file_path_obj.anchor)
152
+ backup_file_name = str(rel_path).replace(os.sep, '_')
153
+
154
+ backup_file = backup_dir / backup_file_name
155
+
156
+ # Write backup file
157
+ await self._write_file_async(backup_file, content)
158
+
159
+ # Compress if requested
160
+ if compress and file_size > 0:
161
+ compressed_file = backup_file.with_suffix(backup_file.suffix + '.gz')
162
+ await self._compress_file(backup_file, compressed_file)
163
+ # Remove uncompressed file
164
+ backup_file.unlink()
165
+ backup_file = compressed_file
166
+ file_size = compressed_file.stat().st_size
167
+
168
+ backup_manifest[file_path] = {
169
+ 'checksum': checksum,
170
+ 'size': file_size,
171
+ 'backup_path': str(backup_file),
172
+ 'original_size': len(content),
173
+ 'compressed': compress and file_size > 0
174
+ }
175
+
176
+ logger.debug(f"Backed up {file_path} -> {backup_file} (checksum: {checksum[:16]}...)")
177
+
178
+ except Exception as e:
179
+ logger.error(f"Failed to backup file {file_path}: {e}")
180
+ # Continue with other files
181
+ continue
182
+
183
+ if not backup_manifest:
184
+ raise ValueError("No files were successfully backed up")
185
+
186
+ # Write manifest
187
+ manifest_path = backup_dir / 'manifest.json'
188
+ manifest_data = {
189
+ 'error_id': error_id,
190
+ 'created_at': timestamp,
191
+ 'files': backup_manifest,
192
+ 'backup_version': 1
193
+ }
194
+ await self._write_file_async(manifest_path, json.dumps(manifest_data, indent=2).encode('utf-8'))
195
+
196
+ # Verify backup integrity if requested
197
+ verified = True
198
+ if verify:
199
+ verified = await self._verify_backup_integrity(backup_dir, backup_manifest)
200
+
201
+ metadata = BackupMetadata(
202
+ error_id=error_id,
203
+ backup_dir=str(backup_dir),
204
+ files=list(backup_manifest.keys()),
205
+ manifest=backup_manifest,
206
+ created_at=timestamp,
207
+ verified=verified
208
+ )
209
+
210
+ logger.info(f"Backup created successfully: {backup_dir} (verified: {verified})")
211
+ return metadata
212
+
213
+ async def _read_file_async(self, file_path: Path) -> bytes:
214
+ """Read file asynchronously."""
215
+ loop = asyncio.get_event_loop()
216
+ with open(file_path, 'rb') as f:
217
+ return await loop.run_in_executor(None, f.read)
218
+
219
+ async def _write_file_async(self, file_path: Path, content: bytes):
220
+ """Write file asynchronously."""
221
+ loop = asyncio.get_event_loop()
222
+ # Ensure parent directory exists
223
+ file_path.parent.mkdir(parents=True, exist_ok=True)
224
+ with open(file_path, 'wb') as f:
225
+ await loop.run_in_executor(None, f.write, content)
226
+
227
+ async def _compress_file(self, source: Path, dest: Path):
228
+ """Compress a file using gzip."""
229
+ loop = asyncio.get_event_loop()
230
+ with open(source, 'rb') as f_in:
231
+ with gzip.open(dest, 'wb') as f_out:
232
+ await loop.run_in_executor(
233
+ None,
234
+ shutil.copyfileobj,
235
+ f_in,
236
+ f_out
237
+ )
238
+
239
+ async def _verify_backup_integrity(
240
+ self,
241
+ backup_dir: Path,
242
+ manifest: Dict[str, Any]
243
+ ) -> bool:
244
+ """
245
+ Verify backup integrity by checking checksums.
246
+
247
+ Returns:
248
+ True if all checksums match, False otherwise
249
+ """
250
+ logger.debug(f"Verifying backup integrity in {backup_dir}")
251
+
252
+ try:
253
+ for file_path, file_info in manifest.items():
254
+ backup_file_path = Path(file_info['backup_path'])
255
+ expected_checksum = file_info['checksum']
256
+
257
+ if not backup_file_path.exists():
258
+ logger.error(f"Backup file not found: {backup_file_path}")
259
+ return False
260
+
261
+ # Read and decompress if needed
262
+ if file_info.get('compressed', False):
263
+ with gzip.open(backup_file_path, 'rb') as f:
264
+ content = f.read()
265
+ else:
266
+ content = await self._read_file_async(backup_file_path)
267
+
268
+ # Verify checksum
269
+ actual_checksum = hashlib.sha256(content).hexdigest()
270
+
271
+ if actual_checksum != expected_checksum:
272
+ logger.error(
273
+ f"Checksum mismatch for {file_path}: "
274
+ f"expected {expected_checksum[:16]}..., got {actual_checksum[:16]}..."
275
+ )
276
+ return False
277
+
278
+ logger.debug(f"Verified {file_path} (checksum: {expected_checksum[:16]}...)")
279
+
280
+ logger.info("Backup integrity verification passed")
281
+ return True
282
+
283
+ except Exception as e:
284
+ logger.error(f"Backup integrity verification failed: {e}")
285
+ return False
286
+
287
+ async def restore_backup(
288
+ self,
289
+ backup_dir: str,
290
+ target_files: Optional[Dict[str, str]] = None,
291
+ max_age_days: Optional[int] = None
292
+ ) -> bool:
293
+ """
294
+ Restore files from a backup.
295
+
296
+ Args:
297
+ backup_dir: Path to backup directory
298
+ target_files: Optional mapping of backup file paths to restore targets
299
+ max_age_days: Optional maximum age of backup in days (security: reject old backups)
300
+
301
+ Returns:
302
+ True if restore was successful, False otherwise
303
+ """
304
+ backup_path = Path(backup_dir)
305
+
306
+ # Security: Validate backup path is within backup root
307
+ try:
308
+ backup_path = backup_path.resolve()
309
+ if not str(backup_path).startswith(str(self.backup_root.resolve())):
310
+ logger.error(f"Backup path outside backup root: {backup_dir}")
311
+ return False
312
+ except Exception as e:
313
+ logger.error(f"Invalid backup path: {backup_dir}: {e}")
314
+ return False
315
+
316
+ if not backup_path.exists():
317
+ logger.error(f"Backup directory not found: {backup_dir}")
318
+ return False
319
+
320
+ manifest_path = backup_path / 'manifest.json'
321
+ if not manifest_path.exists():
322
+ logger.error(f"Manifest not found in backup: {manifest_path}")
323
+ return False
324
+
325
+ try:
326
+ # Load manifest
327
+ manifest_content = await self._read_file_async(manifest_path)
328
+ manifest_data = json.loads(manifest_content.decode('utf-8'))
329
+ files = manifest_data.get('files', {})
330
+
331
+ # Security: Check backup age if max_age_days is specified
332
+ if max_age_days is not None:
333
+ created_at_str = manifest_data.get('created_at', '')
334
+ try:
335
+ created_at = datetime.fromisoformat(created_at_str.replace(':', '-', 2))
336
+ age_days = (datetime.now(timezone.utc) - created_at).days
337
+ if age_days > max_age_days:
338
+ logger.error(
339
+ f"Backup too old to restore: {age_days} days old "
340
+ f"(max allowed: {max_age_days} days)"
341
+ )
342
+ return False
343
+ except Exception as e:
344
+ logger.warning(f"Could not determine backup age: {e}")
345
+ # If we can't determine age, we could be strict and reject, or allow
346
+ # For now, we'll allow but log a warning
347
+
348
+ logger.info(f"Restoring backup from {backup_dir}")
349
+
350
+ # Restore each file
351
+ for original_path, file_info in files.items():
352
+ backup_file_path = Path(file_info['backup_path'])
353
+
354
+ # Determine target file path
355
+ if target_files and original_path in target_files:
356
+ target_path = Path(target_files[original_path])
357
+ else:
358
+ target_path = Path(original_path)
359
+
360
+ # Security: Validate target path to prevent directory traversal
361
+ if not self._validate_file_path(target_path):
362
+ logger.error(f"Invalid or unsafe target path, skipping: {target_path}")
363
+ continue
364
+
365
+ # Ensure target directory exists
366
+ target_path.parent.mkdir(parents=True, exist_ok=True)
367
+
368
+ # Read and decompress if needed
369
+ if file_info.get('compressed', False):
370
+ with gzip.open(backup_file_path, 'rb') as f_in:
371
+ content = f_in.read()
372
+ else:
373
+ content = await self._read_file_async(backup_file_path)
374
+
375
+ # Write restored file
376
+ await self._write_file_async(target_path, content)
377
+
378
+ # Verify restored file checksum
379
+ restored_checksum = hashlib.sha256(content).hexdigest()
380
+ expected_checksum = file_info['checksum']
381
+
382
+ if restored_checksum != expected_checksum:
383
+ logger.error(
384
+ f"Restored file checksum mismatch for {original_path}: "
385
+ f"expected {expected_checksum[:16]}..., got {restored_checksum[:16]}..."
386
+ )
387
+ return False
388
+
389
+ logger.debug(f"Restored {original_path} -> {target_path}")
390
+
391
+ logger.info("Backup restore completed successfully")
392
+ return True
393
+
394
+ except Exception as e:
395
+ logger.error(f"Backup restore failed: {e}")
396
+ return False
397
+
398
+ def list_backups(self, error_id: Optional[str] = None) -> List[Dict[str, Any]]:
399
+ """
400
+ List available backups.
401
+
402
+ Args:
403
+ error_id: Optional filter by error_id
404
+
405
+ Returns:
406
+ List of backup metadata dictionaries
407
+ """
408
+ backups = []
409
+
410
+ if error_id:
411
+ error_dir = self.backup_root / error_id
412
+ if error_dir.exists():
413
+ backup_dirs = [error_dir]
414
+ else:
415
+ return []
416
+ else:
417
+ backup_dirs = [d for d in self.backup_root.iterdir() if d.is_dir()]
418
+
419
+ for error_dir in backup_dirs:
420
+ for backup_dir in error_dir.iterdir():
421
+ if backup_dir.is_dir():
422
+ manifest_path = backup_dir / 'manifest.json'
423
+ if manifest_path.exists():
424
+ try:
425
+ with open(manifest_path, 'r') as f:
426
+ manifest_data = json.load(f)
427
+ backups.append({
428
+ 'error_id': manifest_data.get('error_id'),
429
+ 'backup_dir': str(backup_dir),
430
+ 'created_at': manifest_data.get('created_at'),
431
+ 'files_count': len(manifest_data.get('files', {}))
432
+ })
433
+ except Exception as e:
434
+ logger.warning(f"Failed to read manifest from {manifest_path}: {e}")
435
+
436
+ return backups
437
+
438
+ # Note: cleanup_old_backups() was removed in v1.44. Connector pre-apply
439
+ # backups are intentionally customer-managed with indefinite retention
440
+ # (see help/connectors/python.md and help/error-management/rollback.md).
441
+ # The Patcherly app's own DB-backup retention
442
+ # (server/app/services/db_backup.py) is a separate workflow and is
443
+ # unaffected. Reintroduce only if a tenant or auditor requirement makes
444
+ # connector-side pruning concretely necessary.
445
+
446
+ def _validate_file_path(self, file_path: Path) -> bool:
447
+ """
448
+ Validate file path for security (prevent directory traversal).
449
+
450
+ Args:
451
+ file_path: Path to validate
452
+
453
+ Returns:
454
+ True if path is safe, False otherwise
455
+ """
456
+ try:
457
+ # Resolve to absolute path to normalize it
458
+ resolved = file_path.resolve()
459
+ path_str = str(resolved)
460
+
461
+ # Reject paths that are too long (potential DoS)
462
+ if len(path_str) > 4096: # Common filesystem limit
463
+ logger.warning(f"Path too long: {len(path_str)} characters")
464
+ return False
465
+
466
+ # Check for null bytes (potential injection)
467
+ if '\x00' in path_str:
468
+ logger.warning(f"Path contains null byte: {file_path}")
469
+ return False
470
+
471
+ # Additional validation: ensure resolved path doesn't have suspicious patterns
472
+ # Note: '..' in the original string is dangerous, but after resolve() it's normalized
473
+ # We check the original path string for '..' before resolution
474
+ original_str = str(file_path)
475
+ if '..' in original_str and not resolved.exists():
476
+ # If original had '..' and resolved path doesn't exist, it might be traversal
477
+ logger.debug(f"Path validation: checking for traversal in {original_str}")
478
+
479
+ return True
480
+ except Exception as e:
481
+ logger.error(f"Path validation failed for {file_path}: {e}")
482
+ return False
483
+
484
+ def _ensure_backup_protection(self):
485
+ """Ensure backup directory is protected from direct web access."""
486
+ # Create .htaccess for Apache
487
+ htaccess_file = self.backup_root / '.htaccess'
488
+ if not htaccess_file.exists():
489
+ try:
490
+ with open(htaccess_file, 'w') as f:
491
+ f.write("# Deny all direct access to backup files\n")
492
+ f.write("Order Deny,Allow\n")
493
+ f.write("Deny from all\n")
494
+ f.write("\n# Prevent directory listing\n")
495
+ f.write("Options -Indexes\n")
496
+ except Exception:
497
+ pass # May not have write permissions or not Apache
498
+
499
+ # Create .nginx for Nginx (if using Nginx)
500
+ nginx_file = self.backup_root / '.nginx'
501
+ if not nginx_file.exists():
502
+ try:
503
+ with open(nginx_file, 'w') as f:
504
+ f.write("# Nginx configuration snippet\n")
505
+ f.write("# Add to your Nginx server block:\n")
506
+ f.write("# location ~ ^/(.+\\.patcherly_backups)/ {\n")
507
+ f.write("# deny all;\n")
508
+ f.write("# return 403;\n")
509
+ f.write("# }\n")
510
+ except Exception:
511
+ pass
512
+
513
+ # Create index.html to prevent directory listing
514
+ index_file = self.backup_root / 'index.html'
515
+ if not index_file.exists():
516
+ try:
517
+ with open(index_file, 'w') as f:
518
+ f.write("<!-- Silence is golden. -->\n")
519
+ except Exception:
520
+ pass
521
+