267 lines
8.1 KiB
Python
Executable File
267 lines
8.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Publish a static release as immutable files in Gitea's generic registry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
try:
|
|
from .static_release import ReleaseError, inspect_archive, validate_revision
|
|
except ImportError: # Direct script execution adds scripts/ to sys.path.
|
|
from static_release import ReleaseError, inspect_archive, validate_revision
|
|
|
|
|
|
COMPONENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$")
|
|
ARCHIVE_NAME = "site.tar.gz"
|
|
CHECKSUM_NAME = "site.tar.gz.sha256"
|
|
COORDINATE_NAME = "release-coordinate.json"
|
|
MAX_RESPONSE_BYTES = 200 * 1024 * 1024
|
|
|
|
|
|
class PackageError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def validate_component(value: str, label: str) -> None:
|
|
if not COMPONENT_RE.fullmatch(value):
|
|
raise PackageError(f"{label} contains unsupported characters")
|
|
|
|
|
|
def validate_registry(registry: str, *, allow_http: bool = False) -> str:
|
|
parsed = urllib.parse.urlsplit(registry)
|
|
allowed_schemes = {"https"} | ({"http"} if allow_http else set())
|
|
if (
|
|
parsed.scheme not in allowed_schemes
|
|
or not parsed.netloc
|
|
or parsed.query
|
|
or parsed.fragment
|
|
or parsed.username
|
|
or parsed.password
|
|
):
|
|
raise PackageError("registry must be an HTTPS origin without credentials, query or fragment")
|
|
return registry.rstrip("/")
|
|
|
|
|
|
def package_file_url(
|
|
registry: str,
|
|
owner: str,
|
|
package: str,
|
|
version: str,
|
|
filename: str,
|
|
*,
|
|
allow_http: bool = False,
|
|
) -> str:
|
|
registry = validate_registry(registry, allow_http=allow_http)
|
|
for value, label in ((owner, "owner"), (package, "package"), (filename, "filename")):
|
|
validate_component(value, label)
|
|
validate_revision(version)
|
|
components = [owner, package, version, filename]
|
|
owner_part, package_part, version_part, filename_part = (
|
|
urllib.parse.quote(value, safe="") for value in components
|
|
)
|
|
return (
|
|
f"{registry}/api/packages/{owner_part}/generic/"
|
|
f"{package_part}/{version_part}/{filename_part}"
|
|
)
|
|
|
|
|
|
def authorization(username: str | None, token: str | None) -> str | None:
|
|
if bool(username) != bool(token):
|
|
raise PackageError("package username and token must be supplied together")
|
|
if not username:
|
|
return None
|
|
encoded = base64.b64encode(f"{username}:{token}".encode()).decode("ascii")
|
|
return f"Basic {encoded}"
|
|
|
|
|
|
def request(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
username: str | None,
|
|
token: str | None,
|
|
data: bytes | None = None,
|
|
timeout: float = 300.0,
|
|
) -> tuple[int, bytes]:
|
|
headers = {"User-Agent": "donatella-static-publisher/1"}
|
|
auth = authorization(username, token)
|
|
if auth:
|
|
headers["Authorization"] = auth
|
|
if data is not None:
|
|
headers["Content-Type"] = "application/octet-stream"
|
|
outgoing = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(outgoing, timeout=timeout) as response:
|
|
content = response.read(MAX_RESPONSE_BYTES + 1)
|
|
if len(content) > MAX_RESPONSE_BYTES:
|
|
raise PackageError("package registry response exceeds the size limit")
|
|
return response.status, content
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
content = exc.read(MAX_RESPONSE_BYTES + 1)
|
|
if len(content) > MAX_RESPONSE_BYTES:
|
|
raise PackageError("package registry error response exceeds the size limit")
|
|
return exc.code, content
|
|
finally:
|
|
exc.close()
|
|
except (OSError, urllib.error.URLError) as exc:
|
|
raise PackageError(f"package registry request failed: {exc}") from exc
|
|
|
|
|
|
def put_immutable(
|
|
url: str,
|
|
content: bytes,
|
|
*,
|
|
username: str,
|
|
token: str,
|
|
timeout: float = 300.0,
|
|
) -> None:
|
|
status, existing = request("GET", url, username=username, token=token, timeout=timeout)
|
|
if status == 200:
|
|
if existing != content:
|
|
raise PackageError(f"immutable package file already exists with different content: {url}")
|
|
return
|
|
if status != 404:
|
|
raise PackageError(f"package preflight returned HTTP {status}: {url}")
|
|
|
|
status, _ = request(
|
|
"PUT",
|
|
url,
|
|
username=username,
|
|
token=token,
|
|
data=content,
|
|
timeout=timeout,
|
|
)
|
|
if status not in {201, 409}:
|
|
raise PackageError(f"package upload returned HTTP {status}: {url}")
|
|
|
|
status, published = request("GET", url, username=username, token=token, timeout=timeout)
|
|
if status != 200 or published != content:
|
|
raise PackageError(f"published package file did not verify byte-for-byte: {url}")
|
|
|
|
|
|
def coordinate_bytes(revision: str, artifact_url: str, digest: str) -> bytes:
|
|
return (
|
|
json.dumps(
|
|
{
|
|
"revision": revision,
|
|
"artifact_url": artifact_url,
|
|
"artifact_digest": digest,
|
|
},
|
|
separators=(",", ":"),
|
|
)
|
|
+ "\n"
|
|
).encode("utf-8")
|
|
|
|
|
|
def publish_release(
|
|
*,
|
|
registry: str,
|
|
owner: str,
|
|
package: str,
|
|
revision: str,
|
|
archive: Path,
|
|
checksum: Path,
|
|
coordinate: Path,
|
|
username: str,
|
|
token: str,
|
|
allow_http: bool = False,
|
|
) -> dict[str, str]:
|
|
if not username or not token:
|
|
raise PackageError("package username and token are required")
|
|
if archive.name != ARCHIVE_NAME or checksum.name != CHECKSUM_NAME:
|
|
raise PackageError(f"release files must be named {ARCHIVE_NAME} and {CHECKSUM_NAME}")
|
|
try:
|
|
_, _, digest = inspect_archive(archive, checksum, revision)
|
|
except ReleaseError as exc:
|
|
raise PackageError(str(exc)) from exc
|
|
|
|
artifact_url = package_file_url(
|
|
registry,
|
|
owner,
|
|
package,
|
|
revision,
|
|
ARCHIVE_NAME,
|
|
allow_http=allow_http,
|
|
)
|
|
checksum_url = package_file_url(
|
|
registry,
|
|
owner,
|
|
package,
|
|
revision,
|
|
CHECKSUM_NAME,
|
|
allow_http=allow_http,
|
|
)
|
|
coordinate_url = package_file_url(
|
|
registry,
|
|
owner,
|
|
package,
|
|
revision,
|
|
COORDINATE_NAME,
|
|
allow_http=allow_http,
|
|
)
|
|
payload = coordinate_bytes(revision, artifact_url, digest)
|
|
if coordinate.resolve() in {archive.resolve(), checksum.resolve()}:
|
|
raise PackageError("coordinate output must be separate from the archive and checksum")
|
|
coordinate.parent.mkdir(parents=True, exist_ok=True)
|
|
coordinate.write_bytes(payload)
|
|
|
|
for url, content in (
|
|
(artifact_url, archive.read_bytes()),
|
|
(checksum_url, checksum.read_bytes()),
|
|
(coordinate_url, payload),
|
|
):
|
|
put_immutable(url, content, username=username, token=token)
|
|
|
|
return {
|
|
"revision": revision,
|
|
"artifact_url": artifact_url,
|
|
"artifact_digest": digest,
|
|
}
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
command = argparse.ArgumentParser(description=__doc__)
|
|
command.add_argument("--registry", required=True)
|
|
command.add_argument("--owner", required=True)
|
|
command.add_argument("--package", required=True)
|
|
command.add_argument("--revision", required=True)
|
|
command.add_argument("--archive", type=Path, required=True)
|
|
command.add_argument("--checksum", type=Path, required=True)
|
|
command.add_argument("--coordinate", type=Path, required=True)
|
|
return command
|
|
|
|
|
|
def main() -> int:
|
|
args = parser().parse_args()
|
|
try:
|
|
result = publish_release(
|
|
registry=args.registry,
|
|
owner=args.owner,
|
|
package=args.package,
|
|
revision=args.revision,
|
|
archive=args.archive,
|
|
checksum=args.checksum,
|
|
coordinate=args.coordinate,
|
|
username=os.environ.get("STATIC_PACKAGE_USER", ""),
|
|
token=os.environ.get("STATIC_PACKAGE_TOKEN", ""),
|
|
)
|
|
except PackageError as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 1
|
|
print(json.dumps(result, separators=(",", ":")))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|