diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..837a36b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash(python3:*)", + "Bash(ruff check:*)", + "Bash(for f in src/harbor/*.py)", + "Bash(do python3 -m py_compile \"$f\")", + "Bash(echo:*)", + "Bash(done)", + "Bash(PYTHONPATH=src python3:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(~/git/dotfiles/.local/bin/gitea-ci.py:*)", + "Bash(PYTHONPATH=src python:*)", + "Bash(pip3 install:*)", + "Bash(~/.local/bin/ruff check src/harbor/)" + ] + } +} diff --git a/PROJECT.md b/PROJECT.md index b747dcc..b395b8c 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -48,3 +48,7 @@ Command-line interface for managing Harbor container registry. Provides quick ac - Harbor instance is accessible over HTTPS - User has appropriate permissions for requested operations - Credentials file follows established format when present + +## References + +- Harbor API docs: `~/git/harbor/docs` diff --git a/harbor-ctl.py b/harbor-ctl.py index 5cbfb90..83c29a7 100755 --- a/harbor-ctl.py +++ b/harbor-ctl.py @@ -13,7 +13,7 @@ import urllib.request from pathlib import Path from typing import Any -VERSION = "0.1.0" +VERSION = "0.3.0" # Default config DEFAULT_CREDS = Path("/opt/ansible/secrets/harbor/credentials.json") @@ -427,18 +427,205 @@ def cmd_delete(args, user, password, url): return 1 +def cmd_delete_repo(args, user, password, url): + """Delete an entire repository.""" + project = args.project + repo = args.repo + + # Get artifact count first + data = api_request(f"{url}/api/v2.0/projects/{project}/repositories/{repo}", user, password) + if "error" in data: + print(f"Error: {data}") + return 1 + + artifact_count = data.get("artifact_count", 0) + + if not args.force: + print(f"Will delete repository: {project}/{repo}") + print(f" Artifacts: {artifact_count}") + confirm = input("Confirm deletion? [y/N]: ") + if confirm.lower() != "y": + print("Aborted") + return 1 + + result = api_request( + f"{url}/api/v2.0/projects/{project}/repositories/{repo}", + user, password, method="DELETE" + ) + + if result.get("status") == 200 or not result.get("error"): + print(f"Deleted repository: {project}/{repo} ({artifact_count} artifacts)") + return 0 + else: + print(f"Error: {result}") + return 1 + + +def cmd_delete_project(args, user, password, url): + """Delete an entire project.""" + project = args.project + + # Get project info first + data = api_request(f"{url}/api/v2.0/projects/{project}", user, password) + if "error" in data: + print(f"Error: {data}") + return 1 + + repo_count = data.get("repo_count", 0) + + if repo_count > 0 and not args.force: + print(f"Project {project} has {repo_count} repositories.") + print("Delete all repositories first, or use --force to delete non-empty project.") + return 1 + + if not args.force: + print(f"Will delete project: {project}") + confirm = input("Confirm deletion? [y/N]: ") + if confirm.lower() != "y": + print("Aborted") + return 1 + + result = api_request( + f"{url}/api/v2.0/projects/{project}", + user, password, method="DELETE" + ) + + if result.get("status") == 200 or not result.get("error"): + print(f"Deleted project: {project}") + return 0 + else: + print(f"Error: {result}") + return 1 + + +def cmd_purge(args, user, password, url): + """Delete all artifacts from a repository.""" + project = args.project + repo = args.repo + + # Get all artifacts + artifacts = api_request( + f"{url}/api/v2.0/projects/{project}/repositories/{repo}/artifacts?page_size=100", + user, password + ) + if "error" in artifacts: + print(f"Error: {artifacts}") + return 1 + + if not artifacts: + print("No artifacts to delete") + return 0 + + if not args.force: + print(f"Will delete {len(artifacts)} artifacts from {project}/{repo}") + confirm = input("Confirm deletion? [y/N]: ") + if confirm.lower() != "y": + print("Aborted") + return 1 + + deleted = 0 + errors = 0 + for a in artifacts: + digest = a.get("digest") + if not digest: + continue + + result = api_request( + f"{url}/api/v2.0/projects/{project}/repositories/{repo}/artifacts/{digest}", + user, password, method="DELETE" + ) + + if result.get("status") == 200 or not result.get("error"): + deleted += 1 + print(f" Deleted: {digest[:19]}") + else: + errors += 1 + print(f" Error deleting {digest[:19]}: {result}") + + print(f"\nDeleted {deleted} artifacts, {errors} errors") + return 0 if errors == 0 else 1 + + +def cmd_clean_all(args, user, password, url): + """Delete all artifacts from all repositories in all projects.""" + # Get all projects + projects = api_request(f"{url}/api/v2.0/projects", user, password) + if "error" in projects: + print(f"Error: {projects}") + return 1 + + # Collect summary + total_repos = 0 + total_artifacts = 0 + for p in projects: + repos = api_request(f"{url}/api/v2.0/projects/{p['name']}/repositories", user, password) + if "error" not in repos: + total_repos += len(repos) + for r in repos: + total_artifacts += r.get("artifact_count", 0) + + if total_artifacts == 0: + print("No artifacts to delete") + return 0 + + print(f"Found {len(projects)} projects, {total_repos} repositories, {total_artifacts} artifacts") + + if not args.force: + confirm = input("Delete ALL artifacts? [y/N]: ") + if confirm.lower() != "y": + print("Aborted") + return 1 + + deleted = 0 + errors = 0 + + for p in projects: + project_name = p["name"] + repos = api_request(f"{url}/api/v2.0/projects/{project_name}/repositories", user, password) + if "error" in repos: + continue + + for r in repos: + repo_name = r.get("name", "").replace(f"{project_name}/", "") + artifacts = api_request( + f"{url}/api/v2.0/projects/{project_name}/repositories/{repo_name}/artifacts?page_size=100", + user, password + ) + if "error" in artifacts: + continue + + for a in artifacts: + digest = a.get("digest") + if not digest: + continue + + result = api_request( + f"{url}/api/v2.0/projects/{project_name}/repositories/{repo_name}/artifacts/{digest}", + user, password, method="DELETE" + ) + + if result.get("status") == 200 or not result.get("error"): + deleted += 1 + print(f" Deleted: {project_name}/{repo_name}@{digest[:12]}") + else: + errors += 1 + + print(f"\nDeleted {deleted} artifacts, {errors} errors") + return 0 if errors == 0 else 1 + + def cmd_tags(args, user, password, url): """List or manage tags for an artifact.""" project = args.project repo = args.repo - digest = resolve_digest(project, repo, args.digest, user, password, url) - if not digest: - print("Error: Could not resolve artifact") - return 1 - if args.add: - # Add a new tag + # Add a new tag - need specific artifact + digest = resolve_digest(project, repo, args.digest, user, password, url) + if not digest: + print("Error: Could not resolve artifact") + return 1 + result = api_request( f"{url}/api/v2.0/projects/{project}/repositories/{repo}/artifacts/{digest}/tags", user, password, method="POST", data={"name": args.add} @@ -450,24 +637,39 @@ def cmd_tags(args, user, password, url): print(f"Error: {result}") return 1 - # List tags - artifact = api_request( - f"{url}/api/v2.0/projects/{project}/repositories/{repo}/artifacts/{digest}", + # List all tags across all artifacts in repo + artifacts = api_request( + f"{url}/api/v2.0/projects/{project}/repositories/{repo}/artifacts", user, password ) - if "error" in artifact: - print(f"Error: {artifact}") + if "error" in artifacts: + print(f"Error: {artifacts}") return 1 - tags = artifact.get("tags") or [] - if not tags: + # Collect all tags with their digests + all_tags = [] + for a in artifacts: + digest = a.get("digest", "") + for t in a.get("tags") or []: + all_tags.append({ + "name": t.get("name", "N/A"), + "digest": digest, + "push_time": t.get("push_time", "N/A"), + "immutable": t.get("immutable", False), + }) + + if not all_tags: print("No tags") return 0 - print(f"{'Tag':<30} {'Push Time':<25} {'Immutable':<10}") - print("-" * 70) - for t in tags: - print(f"{t.get('name', 'N/A'):<30} {t.get('push_time', 'N/A'):<25} {t.get('immutable', False)}") + # Sort by push time descending + all_tags.sort(key=lambda x: x["push_time"], reverse=True) + + print(f"{'Tag':<40} {'Digest':<15} {'Push Time':<25}") + print("-" * 82) + for t in all_tags: + digest_short = t["digest"][7:19] if t["digest"].startswith("sha256:") else t["digest"][:12] + print(f"{t['name']:<40} {digest_short:<15} {t['push_time']:<25}") return 0 @@ -566,13 +768,12 @@ Examples: %(prog)s repos library List repos in 'library' project %(prog)s artifacts library flaskpaste List artifacts %(prog)s info library flaskpaste Show latest artifact details - %(prog)s info library flaskpaste -d latest Show artifact by tag %(prog)s vulns library flaskpaste -s high Show high severity vulns - %(prog)s vulns library flaskpaste -P jaraco Filter by package name %(prog)s scan library flaskpaste --wait Scan and wait for completion - %(prog)s tags library flaskpaste List tags for latest artifact - %(prog)s sbom library flaskpaste Show SBOM for artifact %(prog)s config library --show Show project settings + %(prog)s purge library flaskpaste -f Delete all artifacts from repo + %(prog)s delete-repo library flaskpaste -f Delete entire repository + %(prog)s clean-all -f Delete all artifacts everywhere """ ) parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {VERSION}") @@ -599,14 +800,14 @@ Examples: p_info = subparsers.add_parser("info", help="Show artifact details") p_info.add_argument("project", help="Project name") p_info.add_argument("repo", help="Repository name") - p_info.add_argument("-d", "--digest", help="Digest or tag (default: latest)") + p_info.add_argument("-d", "--digest", "--tag", dest="digest", help="Digest or tag (default: latest)") p_info.add_argument("--json", action="store_true", help="Include raw JSON output") # vulns p_vulns = subparsers.add_parser("vulns", help="Show vulnerabilities") p_vulns.add_argument("project", help="Project name") p_vulns.add_argument("repo", help="Repository name") - p_vulns.add_argument("-d", "--digest", help="Digest or tag (default: latest)") + p_vulns.add_argument("-d", "--digest", "--tag", dest="digest", help="Digest or tag (default: latest)") p_vulns.add_argument("-s", "--severity", help="Filter by severity (critical/high/medium/low)") p_vulns.add_argument("-P", "--package", help="Filter by package name (substring match)") p_vulns.add_argument("-l", "--limit", type=int, default=50, help="Max results (default: 50)") @@ -615,30 +816,30 @@ Examples: p_scan = subparsers.add_parser("scan", help="Trigger vulnerability scan") p_scan.add_argument("project", help="Project name") p_scan.add_argument("repo", help="Repository name") - p_scan.add_argument("-d", "--digest", help="Digest or tag (default: latest)") + p_scan.add_argument("-d", "--digest", "--tag", dest="digest", help="Digest or tag (default: latest)") p_scan.add_argument("-w", "--wait", action="store_true", help="Wait for scan to complete") - p_scan.add_argument("-t", "--timeout", type=int, default=120, help="Wait timeout in seconds (default: 120)") + p_scan.add_argument("--timeout", type=int, default=120, help="Wait timeout in seconds (default: 120)") # delete p_delete = subparsers.add_parser("delete", help="Delete artifact or tag") p_delete.add_argument("project", help="Project name") p_delete.add_argument("repo", help="Repository name") p_delete.add_argument("-d", "--digest", help="Digest or tag to delete") - p_delete.add_argument("-T", "--tag", help="Delete specific tag only") + p_delete.add_argument("-T", "--delete-tag", dest="tag", help="Delete specific tag only") p_delete.add_argument("-f", "--force", action="store_true", help="Skip confirmation") # tags p_tags = subparsers.add_parser("tags", help="List or manage tags") p_tags.add_argument("project", help="Project name") p_tags.add_argument("repo", help="Repository name") - p_tags.add_argument("-d", "--digest", help="Digest or tag (default: latest)") + p_tags.add_argument("-d", "--digest", "--tag", dest="digest", help="Digest or tag (for --add)") p_tags.add_argument("-a", "--add", help="Add new tag to artifact") # sbom p_sbom = subparsers.add_parser("sbom", help="Get SBOM for artifact") p_sbom.add_argument("project", help="Project name") p_sbom.add_argument("repo", help="Repository name") - p_sbom.add_argument("-d", "--digest", help="Digest or tag (default: latest)") + p_sbom.add_argument("-d", "--digest", "--tag", dest="digest", help="Digest or tag (default: latest)") p_sbom.add_argument("--json", action="store_true", help="Output raw JSON") p_sbom.add_argument("--summary", action="store_true", help="Summary only, no component list") p_sbom.add_argument("-l", "--limit", type=int, default=50, help="Max components to show (default: 50)") @@ -652,6 +853,27 @@ Examples: p_config.add_argument("--auto-sbom", type=lambda x: x.lower() == "true", metavar="true|false", help="Enable/disable auto-SBOM generation on push") + # delete-repo + p_delete_repo = subparsers.add_parser("delete-repo", help="Delete entire repository") + p_delete_repo.add_argument("project", help="Project name") + p_delete_repo.add_argument("repo", help="Repository name") + p_delete_repo.add_argument("-f", "--force", action="store_true", help="Skip confirmation") + + # delete-project + p_delete_project = subparsers.add_parser("delete-project", help="Delete entire project") + p_delete_project.add_argument("project", help="Project name") + p_delete_project.add_argument("-f", "--force", action="store_true", help="Skip confirmation and delete non-empty") + + # purge + p_purge = subparsers.add_parser("purge", help="Delete all artifacts from a repository") + p_purge.add_argument("project", help="Project name") + p_purge.add_argument("repo", help="Repository name") + p_purge.add_argument("-f", "--force", action="store_true", help="Skip confirmation") + + # clean-all + p_clean_all = subparsers.add_parser("clean-all", help="Delete all artifacts from all repositories") + p_clean_all.add_argument("-f", "--force", action="store_true", help="Skip confirmation") + args = parser.parse_args() if not args.command: @@ -676,6 +898,10 @@ Examples: "vulns": cmd_vulns, "scan": cmd_scan, "delete": cmd_delete, + "delete-repo": cmd_delete_repo, + "delete-project": cmd_delete_project, + "purge": cmd_purge, + "clean-all": cmd_clean_all, "tags": cmd_tags, "sbom": cmd_sbom, "config": cmd_config,