#!/usr/bin/env python3 import argparse import base64 import json import os import pathlib import sys import urllib.error import urllib.parse import urllib.request DEFAULT_REGISTRY_URL = "https://git.thyme-labs.com/api/packages/khodo/generic" DEFAULT_VERSION = "latest" PAGE_LIMIT = 50 def read_config(path): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise SystemExit(f"failed to read {path}: {error}") from error package = data.get("package", {}) if not isinstance(package, dict): raise SystemExit(f"{path}: package must be an object") name = package.get("name") if not isinstance(name, str) or not name: raise SystemExit(f"{path}: package.name must be a non-empty string") artifact = package.get("artifact", {}) if not isinstance(artifact, dict): artifact = {} registry_url = artifact.get("registry_url") if not isinstance(registry_url, str) or not registry_url: registry_url = DEFAULT_REGISTRY_URL return name, registry_url def parse_registry_url(registry_url): parsed = urllib.parse.urlparse(registry_url.rstrip("/")) parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part] if len(parts) < 4 or parts[-4] != "api" or parts[-3] != "packages" or parts[-1] != "generic": raise SystemExit("package artifact registry_url must end with /api/packages/{owner}/generic") owner = parts[-2] prefix = "/" + "/".join(urllib.parse.quote(part, safe="") for part in parts[:-4]) if parts[:-4] else "" return parsed, prefix, owner def build_url(parsed, path, query=""): return urllib.parse.urlunparse(( parsed.scheme, parsed.netloc, path, "", query, "", )) def quote_path(*parts): return "/".join(urllib.parse.quote(part, safe="") for part in parts) def version_list_url(parsed, prefix, owner, package_name, page): path = f"{prefix}/api/v1/packages/{quote_path(owner, 'generic', package_name)}" query = urllib.parse.urlencode({ "page": page, "limit": PAGE_LIMIT, }) return build_url(parsed, path, query) def version_delete_url(parsed, prefix, owner, package_name, version): path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}" return build_url(parsed, path) def file_upload_url(registry_url, package_name, version, path): return "/".join(( registry_url.rstrip("/"), urllib.parse.quote(package_name, safe=""), urllib.parse.quote(version, safe=""), urllib.parse.quote(path.name, safe=""), )) def authorization(user, token): encoded = base64.b64encode(f"{user}:{token}".encode("utf-8")).decode("ascii") return f"Basic {encoded}" def request(method, url, auth, data=None, headers=None, missing_ok=False): request_headers = { "Authorization": auth, **(headers or {}), } request = urllib.request.Request(url, data=data, method=method, headers=request_headers) try: return urllib.request.urlopen(request) except urllib.error.HTTPError as error: if missing_ok and error.code == 404: return None body = error.read().decode("utf-8", errors="replace") message = f"{method} {url} failed with HTTP {error.code}" if body: message += f": {body}" raise SystemExit(message) from error except urllib.error.URLError as error: raise SystemExit(f"{method} {url} failed: {error.reason}") from error def list_versions(parsed, prefix, owner, package_name, auth): versions = [] page = 1 while True: response = request("GET", version_list_url(parsed, prefix, owner, package_name, page), auth, missing_ok=True) if response is None: return [] with response: data = json.loads(response.read().decode("utf-8")) if not isinstance(data, list): raise SystemExit("Gitea package version list response was not an array") if not data: break for item in data: if isinstance(item, dict) and isinstance(item.get("version"), str): versions.append(item["version"]) if len(data) < PAGE_LIMIT: break page += 1 return sorted(set(versions)) def delete_version(parsed, prefix, owner, package_name, version, auth): url = version_delete_url(parsed, prefix, owner, package_name, version) response = request("DELETE", url, auth, missing_ok=True) if response is None: print(f"Package version already absent: {package_name}/{version}") return with response: if response.status != 204: raise SystemExit(f"DELETE {url} returned HTTP {response.status}") print(f"Deleted package version: {package_name}/{version}") def discover_artifacts(dist_dir, package_name, version): archives = sorted(dist_dir.glob(f"{package_name}-{version}-*.tar.gz")) artifacts = [] for archive in archives: artifacts.append(archive) checksum = archive.with_name(archive.name + ".sha256") if checksum.is_file(): artifacts.append(checksum) if not artifacts: raise SystemExit(f"no artifacts found matching {dist_dir}/{package_name}-{version}-*.tar.gz") missing = [path for path in artifacts if not path.is_file()] if missing: raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing)) return artifacts def upload_file(registry_url, package_name, version, path, auth): url = file_upload_url(registry_url, package_name, version, path) headers = { "Content-Length": str(path.stat().st_size), "Content-Type": "application/octet-stream", } with path.open("rb") as input_file: response = request("PUT", url, auth, data=input_file, headers=headers) with response: if response.status != 201: raise SystemExit(f"PUT {url} returned HTTP {response.status}") print(f"Uploaded package file: {package_name}/{version}/{path.name}") def parse_args(argv): parser = argparse.ArgumentParser(description="Replace Product Vamp's rolling Gitea generic package.") parser.add_argument("--config", type=pathlib.Path, default=pathlib.Path("modulus.json")) parser.add_argument("--dist-dir", type=pathlib.Path, default=pathlib.Path("build/packages")) parser.add_argument("--version", default=DEFAULT_VERSION) parser.add_argument("--user", default=None) parser.add_argument("--token", default=None) return parser.parse_args(argv) def main(argv): args = parse_args(argv) package_name, registry_url = read_config(args.config) user = args.user or os_environ("PACKAGE_REGISTRY_USER") token = args.token or os_environ("PACKAGE_REGISTRY_TOKEN") auth = authorization(user, token) parsed, prefix, owner = parse_registry_url(registry_url) artifacts = discover_artifacts(args.dist_dir, package_name, args.version) existing_versions = list_versions(parsed, prefix, owner, package_name, auth) # Gitea rejects duplicate file names within a generic package version, so # replacing the rolling release requires deleting the old version first. if args.version in existing_versions: delete_version(parsed, prefix, owner, package_name, args.version, auth) for artifact in artifacts: upload_file(registry_url, package_name, args.version, artifact, auth) for version in existing_versions: if version != args.version: delete_version(parsed, prefix, owner, package_name, version, auth) return 0 def os_environ(name): value = os.environ.get(name) if not value: raise SystemExit(f"{name} is required") return value if __name__ == "__main__": raise SystemExit(main(sys.argv[1:]))