feat: add --tag alias, improve tags output, add management commands
All checks were successful
CI / Lint & Check (push) Successful in 12s
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:
20
.claude/settings.local.json
Normal file
20
.claude/settings.local.json
Normal 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/)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
284
harbor-ctl.py
284
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,
|
||||
|
||||
Reference in New Issue
Block a user