feat: add --tag alias, improve tags output, add management commands
All checks were successful
CI / Lint & Check (push) Successful in 12s

- add --tag alias for --digest in vulns, scan, info, sbom commands
- tags command now shows digest column for each tag
- add delete-repo, delete-project, purge, clean-all commands
- add config command for project settings
- bump version to 0.3.0
This commit is contained in:
Username
2026-01-23 16:53:26 +01:00
parent 70fc9f1cad
commit 17e2530da9
3 changed files with 279 additions and 29 deletions

View File

@@ -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/)"
]
}
}

View File

@@ -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`

View File

@@ -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,