arch-ops-server 0.1.3__py3-none-any.whl → 3.0.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.
- arch_ops_server/__init__.py +87 -1
- arch_ops_server/config.py +361 -0
- arch_ops_server/logs.py +345 -0
- arch_ops_server/mirrors.py +397 -0
- arch_ops_server/news.py +288 -0
- arch_ops_server/pacman.py +985 -0
- arch_ops_server/server.py +1257 -61
- arch_ops_server/system.py +307 -0
- arch_ops_server-3.0.0.dist-info/METADATA +250 -0
- arch_ops_server-3.0.0.dist-info/RECORD +16 -0
- arch_ops_server-0.1.3.dist-info/METADATA +0 -133
- arch_ops_server-0.1.3.dist-info/RECORD +0 -11
- {arch_ops_server-0.1.3.dist-info → arch_ops_server-3.0.0.dist-info}/WHEEL +0 -0
- {arch_ops_server-0.1.3.dist-info → arch_ops_server-3.0.0.dist-info}/entry_points.txt +0 -0
arch_ops_server/pacman.py
CHANGED
|
@@ -318,3 +318,988 @@ def _parse_checkupdates_output(output: str) -> List[Dict[str, str]]:
|
|
|
318
318
|
|
|
319
319
|
return updates
|
|
320
320
|
|
|
321
|
+
|
|
322
|
+
async def remove_package(
|
|
323
|
+
package_name: str,
|
|
324
|
+
remove_dependencies: bool = False,
|
|
325
|
+
force: bool = False
|
|
326
|
+
) -> Dict[str, Any]:
|
|
327
|
+
"""
|
|
328
|
+
Remove a single package from the system.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
package_name: Name of package to remove
|
|
332
|
+
remove_dependencies: If True, remove unneeded dependencies (pacman -Rs)
|
|
333
|
+
force: If True, force removal ignoring dependencies (pacman -Rdd)
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Dict with removal status and information
|
|
337
|
+
"""
|
|
338
|
+
if not IS_ARCH:
|
|
339
|
+
return create_error_response(
|
|
340
|
+
"NotSupported",
|
|
341
|
+
"Package removal is only available on Arch Linux"
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
if not check_command_exists("pacman"):
|
|
345
|
+
return create_error_response(
|
|
346
|
+
"CommandNotFound",
|
|
347
|
+
"pacman command not found"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
logger.info(f"Removing package: {package_name} (deps={remove_dependencies}, force={force})")
|
|
351
|
+
|
|
352
|
+
# Build command based on options
|
|
353
|
+
cmd = ["sudo", "pacman"]
|
|
354
|
+
|
|
355
|
+
if force:
|
|
356
|
+
cmd.extend(["-Rdd"]) # Force remove, skip dependency checks
|
|
357
|
+
elif remove_dependencies:
|
|
358
|
+
cmd.extend(["-Rs"]) # Remove with unused dependencies
|
|
359
|
+
else:
|
|
360
|
+
cmd.extend(["-R"]) # Basic removal
|
|
361
|
+
|
|
362
|
+
cmd.extend(["--noconfirm", package_name])
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
exit_code, stdout, stderr = await run_command(
|
|
366
|
+
cmd,
|
|
367
|
+
timeout=60, # Longer timeout for removal
|
|
368
|
+
check=False,
|
|
369
|
+
skip_sudo_check=True # We're using sudo in the command
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
if exit_code != 0:
|
|
373
|
+
logger.error(f"Package removal failed: {stderr}")
|
|
374
|
+
return create_error_response(
|
|
375
|
+
"RemovalError",
|
|
376
|
+
f"Failed to remove {package_name}: {stderr}",
|
|
377
|
+
f"Exit code: {exit_code}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
logger.info(f"Successfully removed {package_name}")
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
"success": True,
|
|
384
|
+
"package": package_name,
|
|
385
|
+
"removed_dependencies": remove_dependencies,
|
|
386
|
+
"output": stdout
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
except Exception as e:
|
|
390
|
+
logger.error(f"Package removal failed with exception: {e}")
|
|
391
|
+
return create_error_response(
|
|
392
|
+
"RemovalError",
|
|
393
|
+
f"Failed to remove {package_name}: {str(e)}"
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
async def remove_packages_batch(
|
|
398
|
+
package_names: List[str],
|
|
399
|
+
remove_dependencies: bool = False
|
|
400
|
+
) -> Dict[str, Any]:
|
|
401
|
+
"""
|
|
402
|
+
Remove multiple packages in a single transaction.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
package_names: List of package names to remove
|
|
406
|
+
remove_dependencies: If True, remove unneeded dependencies
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
Dict with removal status
|
|
410
|
+
"""
|
|
411
|
+
if not IS_ARCH:
|
|
412
|
+
return create_error_response(
|
|
413
|
+
"NotSupported",
|
|
414
|
+
"Package removal is only available on Arch Linux"
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if not check_command_exists("pacman"):
|
|
418
|
+
return create_error_response(
|
|
419
|
+
"CommandNotFound",
|
|
420
|
+
"pacman command not found"
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if not package_names:
|
|
424
|
+
return create_error_response(
|
|
425
|
+
"ValidationError",
|
|
426
|
+
"No packages specified for removal"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
logger.info(f"Batch removing {len(package_names)} packages (deps={remove_dependencies})")
|
|
430
|
+
|
|
431
|
+
# Build command
|
|
432
|
+
cmd = ["sudo", "pacman"]
|
|
433
|
+
|
|
434
|
+
if remove_dependencies:
|
|
435
|
+
cmd.extend(["-Rs"])
|
|
436
|
+
else:
|
|
437
|
+
cmd.extend(["-R"])
|
|
438
|
+
|
|
439
|
+
cmd.extend(["--noconfirm"] + package_names)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
exit_code, stdout, stderr = await run_command(
|
|
443
|
+
cmd,
|
|
444
|
+
timeout=120, # Longer timeout for batch removal
|
|
445
|
+
check=False,
|
|
446
|
+
skip_sudo_check=True
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if exit_code != 0:
|
|
450
|
+
logger.error(f"Batch removal failed: {stderr}")
|
|
451
|
+
return create_error_response(
|
|
452
|
+
"RemovalError",
|
|
453
|
+
f"Failed to remove packages: {stderr}",
|
|
454
|
+
f"Exit code: {exit_code}"
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
logger.info(f"Successfully removed {len(package_names)} packages")
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
"success": True,
|
|
461
|
+
"package_count": len(package_names),
|
|
462
|
+
"packages": package_names,
|
|
463
|
+
"removed_dependencies": remove_dependencies,
|
|
464
|
+
"output": stdout
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
except Exception as e:
|
|
468
|
+
logger.error(f"Batch removal failed with exception: {e}")
|
|
469
|
+
return create_error_response(
|
|
470
|
+
"RemovalError",
|
|
471
|
+
f"Failed to remove packages: {str(e)}"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def list_orphan_packages() -> Dict[str, Any]:
|
|
476
|
+
"""
|
|
477
|
+
List all orphaned packages (dependencies no longer required).
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
Dict with list of orphan packages
|
|
481
|
+
"""
|
|
482
|
+
if not IS_ARCH:
|
|
483
|
+
return create_error_response(
|
|
484
|
+
"NotSupported",
|
|
485
|
+
"Orphan package detection is only available on Arch Linux"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if not check_command_exists("pacman"):
|
|
489
|
+
return create_error_response(
|
|
490
|
+
"CommandNotFound",
|
|
491
|
+
"pacman command not found"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
logger.info("Listing orphan packages")
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
exit_code, stdout, stderr = await run_command(
|
|
498
|
+
["pacman", "-Qtdq"],
|
|
499
|
+
timeout=10,
|
|
500
|
+
check=False
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Exit code 1 with no output means no orphans
|
|
504
|
+
if exit_code == 1 and not stdout.strip():
|
|
505
|
+
logger.info("No orphan packages found")
|
|
506
|
+
return {
|
|
507
|
+
"orphan_count": 0,
|
|
508
|
+
"orphans": []
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if exit_code != 0:
|
|
512
|
+
logger.error(f"Failed to list orphans: {stderr}")
|
|
513
|
+
return create_error_response(
|
|
514
|
+
"CommandError",
|
|
515
|
+
f"Failed to list orphan packages: {stderr}",
|
|
516
|
+
f"Exit code: {exit_code}"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Parse output - one package per line
|
|
520
|
+
orphans = [pkg.strip() for pkg in stdout.strip().split('\n') if pkg.strip()]
|
|
521
|
+
|
|
522
|
+
logger.info(f"Found {len(orphans)} orphan packages")
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
"orphan_count": len(orphans),
|
|
526
|
+
"orphans": orphans
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.error(f"Orphan listing failed with exception: {e}")
|
|
531
|
+
return create_error_response(
|
|
532
|
+
"CommandError",
|
|
533
|
+
f"Failed to list orphan packages: {str(e)}"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
async def remove_orphans(dry_run: bool = True, exclude: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
538
|
+
"""
|
|
539
|
+
Remove all orphaned packages.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
dry_run: If True, show what would be removed without actually removing
|
|
543
|
+
exclude: List of packages to exclude from removal
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
Dict with removal status
|
|
547
|
+
"""
|
|
548
|
+
if not IS_ARCH:
|
|
549
|
+
return create_error_response(
|
|
550
|
+
"NotSupported",
|
|
551
|
+
"Orphan removal is only available on Arch Linux"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
if not check_command_exists("pacman"):
|
|
555
|
+
return create_error_response(
|
|
556
|
+
"CommandNotFound",
|
|
557
|
+
"pacman command not found"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# First, get list of orphans
|
|
561
|
+
orphans_result = await list_orphan_packages()
|
|
562
|
+
|
|
563
|
+
if orphans_result.get("error"):
|
|
564
|
+
return orphans_result
|
|
565
|
+
|
|
566
|
+
orphans = orphans_result.get("orphans", [])
|
|
567
|
+
|
|
568
|
+
if not orphans:
|
|
569
|
+
return {
|
|
570
|
+
"removed_count": 0,
|
|
571
|
+
"packages": [],
|
|
572
|
+
"message": "No orphan packages to remove"
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
# Apply exclusions if provided
|
|
576
|
+
if exclude:
|
|
577
|
+
orphans = [pkg for pkg in orphans if pkg not in exclude]
|
|
578
|
+
if not orphans:
|
|
579
|
+
return {
|
|
580
|
+
"removed_count": 0,
|
|
581
|
+
"packages": [],
|
|
582
|
+
"message": "All orphan packages are in exclusion list"
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
logger.info(f"Removing {len(orphans)} orphan packages (dry_run={dry_run})")
|
|
586
|
+
|
|
587
|
+
if dry_run:
|
|
588
|
+
return {
|
|
589
|
+
"dry_run": True,
|
|
590
|
+
"would_remove_count": len(orphans),
|
|
591
|
+
"packages": orphans,
|
|
592
|
+
"message": "This is a dry run. No packages were removed."
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
# Remove orphans using pacman -Rns
|
|
597
|
+
cmd = ["sudo", "pacman", "-Rns", "--noconfirm"] + orphans
|
|
598
|
+
|
|
599
|
+
exit_code, stdout, stderr = await run_command(
|
|
600
|
+
cmd,
|
|
601
|
+
timeout=120,
|
|
602
|
+
check=False,
|
|
603
|
+
skip_sudo_check=True
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
if exit_code != 0:
|
|
607
|
+
logger.error(f"Orphan removal failed: {stderr}")
|
|
608
|
+
return create_error_response(
|
|
609
|
+
"RemovalError",
|
|
610
|
+
f"Failed to remove orphan packages: {stderr}",
|
|
611
|
+
f"Exit code: {exit_code}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
logger.info(f"Successfully removed {len(orphans)} orphan packages")
|
|
615
|
+
|
|
616
|
+
return {
|
|
617
|
+
"success": True,
|
|
618
|
+
"removed_count": len(orphans),
|
|
619
|
+
"packages": orphans,
|
|
620
|
+
"output": stdout
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
except Exception as e:
|
|
624
|
+
logger.error(f"Orphan removal failed with exception: {e}")
|
|
625
|
+
return create_error_response(
|
|
626
|
+
"RemovalError",
|
|
627
|
+
f"Failed to remove orphan packages: {str(e)}"
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def find_package_owner(file_path: str) -> Dict[str, Any]:
|
|
632
|
+
"""
|
|
633
|
+
Find which package owns a specific file.
|
|
634
|
+
|
|
635
|
+
Args:
|
|
636
|
+
file_path: Absolute path to file
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
Dict with package owner information
|
|
640
|
+
"""
|
|
641
|
+
if not IS_ARCH:
|
|
642
|
+
return create_error_response(
|
|
643
|
+
"NotSupported",
|
|
644
|
+
"Package ownership queries are only available on Arch Linux"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
if not check_command_exists("pacman"):
|
|
648
|
+
return create_error_response(
|
|
649
|
+
"CommandNotFound",
|
|
650
|
+
"pacman command not found"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
logger.info(f"Finding owner of file: {file_path}")
|
|
654
|
+
|
|
655
|
+
try:
|
|
656
|
+
exit_code, stdout, stderr = await run_command(
|
|
657
|
+
["pacman", "-Qo", file_path],
|
|
658
|
+
timeout=5,
|
|
659
|
+
check=False
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if exit_code != 0:
|
|
663
|
+
logger.info(f"No package owns {file_path}")
|
|
664
|
+
return create_error_response(
|
|
665
|
+
"NotFound",
|
|
666
|
+
f"No package owns this file: {file_path}",
|
|
667
|
+
stderr
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
# Parse output: "/path/to/file is owned by package 1.0-1"
|
|
671
|
+
match = re.search(r'is owned by (\S+)\s+(\S+)', stdout)
|
|
672
|
+
if match:
|
|
673
|
+
package_name = match.group(1)
|
|
674
|
+
version = match.group(2)
|
|
675
|
+
|
|
676
|
+
logger.info(f"File {file_path} is owned by {package_name} {version}")
|
|
677
|
+
|
|
678
|
+
return {
|
|
679
|
+
"file": file_path,
|
|
680
|
+
"package": package_name,
|
|
681
|
+
"version": version
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return create_error_response(
|
|
685
|
+
"ParseError",
|
|
686
|
+
f"Could not parse pacman output: {stdout}"
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
except Exception as e:
|
|
690
|
+
logger.error(f"Package ownership query failed: {e}")
|
|
691
|
+
return create_error_response(
|
|
692
|
+
"CommandError",
|
|
693
|
+
f"Failed to find package owner: {str(e)}"
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
async def list_package_files(package_name: str, filter_pattern: Optional[str] = None) -> Dict[str, Any]:
|
|
698
|
+
"""
|
|
699
|
+
List all files owned by a package.
|
|
700
|
+
|
|
701
|
+
Args:
|
|
702
|
+
package_name: Name of package
|
|
703
|
+
filter_pattern: Optional regex pattern to filter files
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
Dict with list of files
|
|
707
|
+
"""
|
|
708
|
+
if not IS_ARCH:
|
|
709
|
+
return create_error_response(
|
|
710
|
+
"NotSupported",
|
|
711
|
+
"Package file listing is only available on Arch Linux"
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
if not check_command_exists("pacman"):
|
|
715
|
+
return create_error_response(
|
|
716
|
+
"CommandNotFound",
|
|
717
|
+
"pacman command not found"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
logger.info(f"Listing files for package: {package_name}")
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
exit_code, stdout, stderr = await run_command(
|
|
724
|
+
["pacman", "-Ql", package_name],
|
|
725
|
+
timeout=10,
|
|
726
|
+
check=False
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
if exit_code != 0:
|
|
730
|
+
logger.error(f"Failed to list files for {package_name}: {stderr}")
|
|
731
|
+
return create_error_response(
|
|
732
|
+
"NotFound",
|
|
733
|
+
f"Package not found or no files: {package_name}",
|
|
734
|
+
stderr
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Parse output: "package /path/to/file"
|
|
738
|
+
files = []
|
|
739
|
+
for line in stdout.strip().split('\n'):
|
|
740
|
+
if not line.strip():
|
|
741
|
+
continue
|
|
742
|
+
|
|
743
|
+
parts = line.split(maxsplit=1)
|
|
744
|
+
if len(parts) == 2:
|
|
745
|
+
file_path = parts[1]
|
|
746
|
+
|
|
747
|
+
# Apply filter if provided
|
|
748
|
+
if filter_pattern:
|
|
749
|
+
if re.search(filter_pattern, file_path):
|
|
750
|
+
files.append(file_path)
|
|
751
|
+
else:
|
|
752
|
+
files.append(file_path)
|
|
753
|
+
|
|
754
|
+
logger.info(f"Found {len(files)} files for {package_name}")
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
"package": package_name,
|
|
758
|
+
"file_count": len(files),
|
|
759
|
+
"files": files,
|
|
760
|
+
"filter_applied": filter_pattern is not None
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
except Exception as e:
|
|
764
|
+
logger.error(f"File listing failed: {e}")
|
|
765
|
+
return create_error_response(
|
|
766
|
+
"CommandError",
|
|
767
|
+
f"Failed to list package files: {str(e)}"
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
async def search_package_files(filename_pattern: str) -> Dict[str, Any]:
|
|
772
|
+
"""
|
|
773
|
+
Search for files across all packages.
|
|
774
|
+
|
|
775
|
+
Args:
|
|
776
|
+
filename_pattern: Filename pattern to search for
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
Dict with matching files and packages
|
|
780
|
+
"""
|
|
781
|
+
if not IS_ARCH:
|
|
782
|
+
return create_error_response(
|
|
783
|
+
"NotSupported",
|
|
784
|
+
"Package file search is only available on Arch Linux"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
if not check_command_exists("pacman"):
|
|
788
|
+
return create_error_response(
|
|
789
|
+
"CommandNotFound",
|
|
790
|
+
"pacman command not found"
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
logger.info(f"Searching for files matching: {filename_pattern}")
|
|
794
|
+
|
|
795
|
+
try:
|
|
796
|
+
# First check if file database is synced
|
|
797
|
+
exit_code, stdout, stderr = await run_command(
|
|
798
|
+
["pacman", "-F", filename_pattern],
|
|
799
|
+
timeout=30,
|
|
800
|
+
check=False
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
if exit_code == 1 and "database" in stderr.lower():
|
|
804
|
+
return create_error_response(
|
|
805
|
+
"DatabaseNotSynced",
|
|
806
|
+
"Package file database not synced. Run 'sudo pacman -Fy' first.",
|
|
807
|
+
"File database needs to be synchronized before searching"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
if exit_code != 0 and not stdout.strip():
|
|
811
|
+
logger.info(f"No files found matching {filename_pattern}")
|
|
812
|
+
return {
|
|
813
|
+
"pattern": filename_pattern,
|
|
814
|
+
"match_count": 0,
|
|
815
|
+
"matches": []
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
# Parse output: "repository/package version\n path/to/file"
|
|
819
|
+
matches = []
|
|
820
|
+
current_package = None
|
|
821
|
+
|
|
822
|
+
for line in stdout.strip().split('\n'):
|
|
823
|
+
if not line.strip():
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
if line.startswith(' '):
|
|
827
|
+
# This is a file path
|
|
828
|
+
if current_package:
|
|
829
|
+
file_path = line.strip()
|
|
830
|
+
matches.append({
|
|
831
|
+
"package": current_package["package"],
|
|
832
|
+
"repository": current_package["repository"],
|
|
833
|
+
"version": current_package["version"],
|
|
834
|
+
"file": file_path
|
|
835
|
+
})
|
|
836
|
+
else:
|
|
837
|
+
# This is a package line: "repository/package version"
|
|
838
|
+
parts = line.split()
|
|
839
|
+
if len(parts) >= 2:
|
|
840
|
+
repo_pkg = parts[0].split('/')
|
|
841
|
+
if len(repo_pkg) == 2:
|
|
842
|
+
current_package = {
|
|
843
|
+
"repository": repo_pkg[0],
|
|
844
|
+
"package": repo_pkg[1],
|
|
845
|
+
"version": parts[1]
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
logger.info(f"Found {len(matches)} files matching {filename_pattern}")
|
|
849
|
+
|
|
850
|
+
return {
|
|
851
|
+
"pattern": filename_pattern,
|
|
852
|
+
"match_count": len(matches),
|
|
853
|
+
"matches": matches
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
except Exception as e:
|
|
857
|
+
logger.error(f"File search failed: {e}")
|
|
858
|
+
return create_error_response(
|
|
859
|
+
"CommandError",
|
|
860
|
+
f"Failed to search package files: {str(e)}"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
async def verify_package_integrity(package_name: str, thorough: bool = False) -> Dict[str, Any]:
|
|
865
|
+
"""
|
|
866
|
+
Verify integrity of an installed package.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
package_name: Name of package to verify
|
|
870
|
+
thorough: If True, perform thorough check (pacman -Qkk)
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Dict with verification results
|
|
874
|
+
"""
|
|
875
|
+
if not IS_ARCH:
|
|
876
|
+
return create_error_response(
|
|
877
|
+
"NotSupported",
|
|
878
|
+
"Package verification is only available on Arch Linux"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
if not check_command_exists("pacman"):
|
|
882
|
+
return create_error_response(
|
|
883
|
+
"CommandNotFound",
|
|
884
|
+
"pacman command not found"
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
logger.info(f"Verifying package integrity: {package_name} (thorough={thorough})")
|
|
888
|
+
|
|
889
|
+
try:
|
|
890
|
+
cmd = ["pacman", "-Qkk" if thorough else "-Qk", package_name]
|
|
891
|
+
|
|
892
|
+
exit_code, stdout, stderr = await run_command(
|
|
893
|
+
cmd,
|
|
894
|
+
timeout=30,
|
|
895
|
+
check=False
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
if exit_code != 0 and "was not found" in stderr:
|
|
899
|
+
return create_error_response(
|
|
900
|
+
"NotFound",
|
|
901
|
+
f"Package not installed: {package_name}"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
# Parse verification output
|
|
905
|
+
issues = []
|
|
906
|
+
for line in stdout.strip().split('\n'):
|
|
907
|
+
if "warning" in line.lower() or "missing" in line.lower():
|
|
908
|
+
issues.append(line.strip())
|
|
909
|
+
|
|
910
|
+
logger.info(f"Found {len(issues)} issues for {package_name}")
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
"package": package_name,
|
|
914
|
+
"thorough": thorough,
|
|
915
|
+
"issues_found": len(issues),
|
|
916
|
+
"issues": issues,
|
|
917
|
+
"all_ok": len(issues) == 0
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
except Exception as e:
|
|
921
|
+
logger.error(f"Package verification failed: {e}")
|
|
922
|
+
return create_error_response(
|
|
923
|
+
"CommandError",
|
|
924
|
+
f"Failed to verify package: {str(e)}"
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
async def list_package_groups() -> Dict[str, Any]:
|
|
929
|
+
"""
|
|
930
|
+
List all available package groups.
|
|
931
|
+
|
|
932
|
+
Returns:
|
|
933
|
+
Dict with list of groups
|
|
934
|
+
"""
|
|
935
|
+
if not IS_ARCH:
|
|
936
|
+
return create_error_response(
|
|
937
|
+
"NotSupported",
|
|
938
|
+
"Package groups are only available on Arch Linux"
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
if not check_command_exists("pacman"):
|
|
942
|
+
return create_error_response(
|
|
943
|
+
"CommandNotFound",
|
|
944
|
+
"pacman command not found"
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
logger.info("Listing package groups")
|
|
948
|
+
|
|
949
|
+
try:
|
|
950
|
+
exit_code, stdout, stderr = await run_command(
|
|
951
|
+
["pacman", "-Sg"],
|
|
952
|
+
timeout=10,
|
|
953
|
+
check=False
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
if exit_code != 0:
|
|
957
|
+
return create_error_response(
|
|
958
|
+
"CommandError",
|
|
959
|
+
f"Failed to list groups: {stderr}"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
# Parse output - format: "group package"
|
|
963
|
+
groups = set()
|
|
964
|
+
for line in stdout.strip().split('\n'):
|
|
965
|
+
if line.strip():
|
|
966
|
+
parts = line.split()
|
|
967
|
+
if parts:
|
|
968
|
+
groups.add(parts[0])
|
|
969
|
+
|
|
970
|
+
groups_list = sorted(list(groups))
|
|
971
|
+
|
|
972
|
+
logger.info(f"Found {len(groups_list)} package groups")
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
"group_count": len(groups_list),
|
|
976
|
+
"groups": groups_list
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
except Exception as e:
|
|
980
|
+
logger.error(f"Failed to list groups: {e}")
|
|
981
|
+
return create_error_response(
|
|
982
|
+
"CommandError",
|
|
983
|
+
f"Failed to list package groups: {str(e)}"
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
async def list_group_packages(group_name: str) -> Dict[str, Any]:
|
|
988
|
+
"""
|
|
989
|
+
List packages in a specific group.
|
|
990
|
+
|
|
991
|
+
Args:
|
|
992
|
+
group_name: Name of the group
|
|
993
|
+
|
|
994
|
+
Returns:
|
|
995
|
+
Dict with packages in the group
|
|
996
|
+
"""
|
|
997
|
+
if not IS_ARCH:
|
|
998
|
+
return create_error_response(
|
|
999
|
+
"NotSupported",
|
|
1000
|
+
"Package groups are only available on Arch Linux"
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
if not check_command_exists("pacman"):
|
|
1004
|
+
return create_error_response(
|
|
1005
|
+
"CommandNotFound",
|
|
1006
|
+
"pacman command not found"
|
|
1007
|
+
)
|
|
1008
|
+
|
|
1009
|
+
logger.info(f"Listing packages in group: {group_name}")
|
|
1010
|
+
|
|
1011
|
+
try:
|
|
1012
|
+
exit_code, stdout, stderr = await run_command(
|
|
1013
|
+
["pacman", "-Sg", group_name],
|
|
1014
|
+
timeout=10,
|
|
1015
|
+
check=False
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
if exit_code != 0:
|
|
1019
|
+
return create_error_response(
|
|
1020
|
+
"NotFound",
|
|
1021
|
+
f"Group not found: {group_name}"
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
# Parse output - format: "group package"
|
|
1025
|
+
packages = []
|
|
1026
|
+
for line in stdout.strip().split('\n'):
|
|
1027
|
+
if line.strip():
|
|
1028
|
+
parts = line.split()
|
|
1029
|
+
if len(parts) >= 2:
|
|
1030
|
+
packages.append(parts[1])
|
|
1031
|
+
|
|
1032
|
+
logger.info(f"Found {len(packages)} packages in {group_name}")
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
"group": group_name,
|
|
1036
|
+
"package_count": len(packages),
|
|
1037
|
+
"packages": packages
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
except Exception as e:
|
|
1041
|
+
logger.error(f"Failed to list group packages: {e}")
|
|
1042
|
+
return create_error_response(
|
|
1043
|
+
"CommandError",
|
|
1044
|
+
f"Failed to list packages in group: {str(e)}"
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
async def list_explicit_packages() -> Dict[str, Any]:
|
|
1049
|
+
"""
|
|
1050
|
+
List explicitly installed packages.
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
Dict with list of explicit packages
|
|
1054
|
+
"""
|
|
1055
|
+
if not IS_ARCH:
|
|
1056
|
+
return create_error_response(
|
|
1057
|
+
"NotSupported",
|
|
1058
|
+
"Package install reason queries are only available on Arch Linux"
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
if not check_command_exists("pacman"):
|
|
1062
|
+
return create_error_response(
|
|
1063
|
+
"CommandNotFound",
|
|
1064
|
+
"pacman command not found"
|
|
1065
|
+
)
|
|
1066
|
+
|
|
1067
|
+
logger.info("Listing explicitly installed packages")
|
|
1068
|
+
|
|
1069
|
+
try:
|
|
1070
|
+
exit_code, stdout, stderr = await run_command(
|
|
1071
|
+
["pacman", "-Qe"],
|
|
1072
|
+
timeout=15,
|
|
1073
|
+
check=False
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
if exit_code != 0:
|
|
1077
|
+
return create_error_response(
|
|
1078
|
+
"CommandError",
|
|
1079
|
+
f"Failed to list explicit packages: {stderr}"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
# Parse output - format: "package version"
|
|
1083
|
+
packages = []
|
|
1084
|
+
for line in stdout.strip().split('\n'):
|
|
1085
|
+
if line.strip():
|
|
1086
|
+
parts = line.split()
|
|
1087
|
+
if len(parts) >= 2:
|
|
1088
|
+
packages.append({
|
|
1089
|
+
"name": parts[0],
|
|
1090
|
+
"version": parts[1]
|
|
1091
|
+
})
|
|
1092
|
+
|
|
1093
|
+
logger.info(f"Found {len(packages)} explicitly installed packages")
|
|
1094
|
+
|
|
1095
|
+
return {
|
|
1096
|
+
"package_count": len(packages),
|
|
1097
|
+
"packages": packages
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
except Exception as e:
|
|
1101
|
+
logger.error(f"Failed to list explicit packages: {e}")
|
|
1102
|
+
return create_error_response(
|
|
1103
|
+
"CommandError",
|
|
1104
|
+
f"Failed to list explicit packages: {str(e)}"
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
async def mark_as_explicit(package_name: str) -> Dict[str, Any]:
|
|
1109
|
+
"""
|
|
1110
|
+
Mark a package as explicitly installed.
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
package_name: Name of package to mark
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
Dict with operation status
|
|
1117
|
+
"""
|
|
1118
|
+
if not IS_ARCH:
|
|
1119
|
+
return create_error_response(
|
|
1120
|
+
"NotSupported",
|
|
1121
|
+
"Package marking is only available on Arch Linux"
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
if not check_command_exists("pacman"):
|
|
1125
|
+
return create_error_response(
|
|
1126
|
+
"CommandNotFound",
|
|
1127
|
+
"pacman command not found"
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
logger.info(f"Marking {package_name} as explicitly installed")
|
|
1131
|
+
|
|
1132
|
+
try:
|
|
1133
|
+
exit_code, stdout, stderr = await run_command(
|
|
1134
|
+
["sudo", "pacman", "-D", "--asexplicit", package_name],
|
|
1135
|
+
timeout=10,
|
|
1136
|
+
check=False,
|
|
1137
|
+
skip_sudo_check=True
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
if exit_code != 0:
|
|
1141
|
+
return create_error_response(
|
|
1142
|
+
"CommandError",
|
|
1143
|
+
f"Failed to mark package as explicit: {stderr}"
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
logger.info(f"Successfully marked {package_name} as explicit")
|
|
1147
|
+
|
|
1148
|
+
return {
|
|
1149
|
+
"success": True,
|
|
1150
|
+
"package": package_name,
|
|
1151
|
+
"marked_as": "explicit"
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
except Exception as e:
|
|
1155
|
+
logger.error(f"Failed to mark package: {e}")
|
|
1156
|
+
return create_error_response(
|
|
1157
|
+
"CommandError",
|
|
1158
|
+
f"Failed to mark package as explicit: {str(e)}"
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
async def mark_as_dependency(package_name: str) -> Dict[str, Any]:
|
|
1163
|
+
"""
|
|
1164
|
+
Mark a package as a dependency.
|
|
1165
|
+
|
|
1166
|
+
Args:
|
|
1167
|
+
package_name: Name of package to mark
|
|
1168
|
+
|
|
1169
|
+
Returns:
|
|
1170
|
+
Dict with operation status
|
|
1171
|
+
"""
|
|
1172
|
+
if not IS_ARCH:
|
|
1173
|
+
return create_error_response(
|
|
1174
|
+
"NotSupported",
|
|
1175
|
+
"Package marking is only available on Arch Linux"
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
if not check_command_exists("pacman"):
|
|
1179
|
+
return create_error_response(
|
|
1180
|
+
"CommandNotFound",
|
|
1181
|
+
"pacman command not found"
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
logger.info(f"Marking {package_name} as dependency")
|
|
1185
|
+
|
|
1186
|
+
try:
|
|
1187
|
+
exit_code, stdout, stderr = await run_command(
|
|
1188
|
+
["sudo", "pacman", "-D", "--asdeps", package_name],
|
|
1189
|
+
timeout=10,
|
|
1190
|
+
check=False,
|
|
1191
|
+
skip_sudo_check=True
|
|
1192
|
+
)
|
|
1193
|
+
|
|
1194
|
+
if exit_code != 0:
|
|
1195
|
+
return create_error_response(
|
|
1196
|
+
"CommandError",
|
|
1197
|
+
f"Failed to mark package as dependency: {stderr}"
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
logger.info(f"Successfully marked {package_name} as dependency")
|
|
1201
|
+
|
|
1202
|
+
return {
|
|
1203
|
+
"success": True,
|
|
1204
|
+
"package": package_name,
|
|
1205
|
+
"marked_as": "dependency"
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
except Exception as e:
|
|
1209
|
+
logger.error(f"Failed to mark package: {e}")
|
|
1210
|
+
return create_error_response(
|
|
1211
|
+
"CommandError",
|
|
1212
|
+
f"Failed to mark package as dependency: {str(e)}"
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
async def check_database_freshness() -> Dict[str, Any]:
|
|
1217
|
+
"""
|
|
1218
|
+
Check when package databases were last synchronized.
|
|
1219
|
+
|
|
1220
|
+
Returns:
|
|
1221
|
+
Dict with database sync timestamps per repository
|
|
1222
|
+
"""
|
|
1223
|
+
if not IS_ARCH:
|
|
1224
|
+
return create_error_response(
|
|
1225
|
+
"NotSupported",
|
|
1226
|
+
"Database freshness check is only available on Arch Linux"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
logger.info("Checking database freshness")
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
from pathlib import Path
|
|
1233
|
+
from datetime import datetime, timedelta
|
|
1234
|
+
|
|
1235
|
+
sync_dir = Path("/var/lib/pacman/sync")
|
|
1236
|
+
|
|
1237
|
+
if not sync_dir.exists():
|
|
1238
|
+
return create_error_response(
|
|
1239
|
+
"NotFound",
|
|
1240
|
+
"Pacman sync directory not found"
|
|
1241
|
+
)
|
|
1242
|
+
|
|
1243
|
+
# Get all .db files
|
|
1244
|
+
db_files = list(sync_dir.glob("*.db"))
|
|
1245
|
+
|
|
1246
|
+
if not db_files:
|
|
1247
|
+
return create_error_response(
|
|
1248
|
+
"NotFound",
|
|
1249
|
+
"No database files found"
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
databases = []
|
|
1253
|
+
now = datetime.now()
|
|
1254
|
+
oldest_db = None
|
|
1255
|
+
oldest_age = timedelta(0)
|
|
1256
|
+
|
|
1257
|
+
for db_file in db_files:
|
|
1258
|
+
mtime = datetime.fromtimestamp(db_file.stat().st_mtime)
|
|
1259
|
+
age = now - mtime
|
|
1260
|
+
hours_old = age.total_seconds() / 3600
|
|
1261
|
+
|
|
1262
|
+
db_info = {
|
|
1263
|
+
"repository": db_file.stem, # Remove .db extension
|
|
1264
|
+
"last_sync": mtime.isoformat(),
|
|
1265
|
+
"hours_old": round(hours_old, 1)
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
# Warn if older than 24 hours
|
|
1269
|
+
if hours_old > 24:
|
|
1270
|
+
db_info["warning"] = f"Database is {hours_old:.0f} hours old (> 24h)"
|
|
1271
|
+
|
|
1272
|
+
databases.append(db_info)
|
|
1273
|
+
|
|
1274
|
+
# Track oldest
|
|
1275
|
+
if oldest_db is None or age > oldest_age:
|
|
1276
|
+
oldest_db = db_info["repository"]
|
|
1277
|
+
oldest_age = age
|
|
1278
|
+
|
|
1279
|
+
# Sort by hours_old descending (oldest first)
|
|
1280
|
+
databases.sort(key=lambda x: x["hours_old"], reverse=True)
|
|
1281
|
+
|
|
1282
|
+
logger.info(f"Checked {len(databases)} databases, oldest: {oldest_age.total_seconds() / 3600:.1f}h")
|
|
1283
|
+
|
|
1284
|
+
recommendations = []
|
|
1285
|
+
if oldest_age.total_seconds() / 3600 > 24:
|
|
1286
|
+
recommendations.append("Databases are stale (> 24h). Run 'sudo pacman -Sy' to synchronize.")
|
|
1287
|
+
if oldest_age.total_seconds() / 3600 > 168: # 1 week
|
|
1288
|
+
recommendations.append("Databases are very stale (> 1 week). Consider full system update.")
|
|
1289
|
+
|
|
1290
|
+
return {
|
|
1291
|
+
"database_count": len(databases),
|
|
1292
|
+
"databases": databases,
|
|
1293
|
+
"oldest_database": oldest_db,
|
|
1294
|
+
"oldest_age_hours": round(oldest_age.total_seconds() / 3600, 1),
|
|
1295
|
+
"recommendations": recommendations,
|
|
1296
|
+
"needs_sync": oldest_age.total_seconds() / 3600 > 24
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
except Exception as e:
|
|
1300
|
+
logger.error(f"Failed to check database freshness: {e}")
|
|
1301
|
+
return create_error_response(
|
|
1302
|
+
"CheckError",
|
|
1303
|
+
f"Failed to check database freshness: {str(e)}"
|
|
1304
|
+
)
|
|
1305
|
+
|