197 lines
7.3 KiB
Python
Executable File
197 lines
7.3 KiB
Python
Executable File
#!/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:]))
|