mirror of
https://github.com/xcat2/confluent.git
synced 2026-09-02 15:36:05 +00:00
Attempt a new 'osdeploy fetch' to help with downloading some of the ISOs.
This commit is contained in:
@@ -17,6 +17,7 @@ if path.startswith('/opt'):
|
||||
sys.path.append(path)
|
||||
import confluent.collective.manager as collective
|
||||
import confluent.selfservice as selfservice
|
||||
import confluent.deployment.download as download
|
||||
import confluent.util as util
|
||||
import confluent.asynclient as client
|
||||
import confluent.sshutil as sshutil
|
||||
@@ -54,6 +55,9 @@ async def main(args):
|
||||
osip = sp.add_parser('import', help='Import an OS image from an ISO image')
|
||||
osip.add_argument('imagefile', help='File to use for source of importing')
|
||||
osip.add_argument('-n', help='Specific a custom distribution name')
|
||||
fetchargs = sp.add_parser('fetch', help='Fetch an OS image from a remote source')
|
||||
fetchargs.add_argument('distname', help='Distribution name to fetch')
|
||||
fetchargs.add_argument('destdir', help='Directory to save the fetched image in')
|
||||
upb = sp.add_parser(
|
||||
'updateboot',
|
||||
help='Push profile.yaml of the named profile data into boot assets as appropriate')
|
||||
@@ -65,6 +69,8 @@ async def main(args):
|
||||
|
||||
if cmdset.command == 'list':
|
||||
return await oslist()
|
||||
if cmdset.command == 'fetch':
|
||||
return await download_image(cmdset.distname, cmdset.destdir)
|
||||
if cmdset.command == 'import':
|
||||
return await osimport(cmdset.imagefile, custname=cmdset.n)
|
||||
if cmdset.command == 'importcheck':
|
||||
@@ -536,6 +542,21 @@ async def oslist():
|
||||
print("")
|
||||
|
||||
|
||||
async def download_image(distname, destdir):
|
||||
dlinfo = await download.get_dlinfo(distname)
|
||||
if not dlinfo.iso_url:
|
||||
sys.stderr.write('No download information available for {0}\n'.format(distname))
|
||||
sys.exit(1)
|
||||
if not os.path.exists(destdir):
|
||||
sys.stderr.write('Destination directory {0} does not exist\n'.format(destdir))
|
||||
sys.exit(1)
|
||||
destfile = os.path.join(destdir, dlinfo.iso_name)
|
||||
if os.path.exists(destfile):
|
||||
sys.stderr.write('Destination file {0} already exists\n'.format(destfile))
|
||||
sys.exit(1)
|
||||
print('Downloading {0} to {1}'.format(dlinfo.iso_url, destfile))
|
||||
await download.download_dist(destfile, destdir, dlinfo=dlinfo)
|
||||
|
||||
async def osimport(imagefile, checkonly=False, custname=None):
|
||||
c = client.Command()
|
||||
imagefile = os.path.abspath(imagefile)
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Manage retrieval of OS material from locations, including GPG validation where possible."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from html.parser import HTMLParser
|
||||
|
||||
import aiohttp
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadInfo:
|
||||
"""A single downloadable ISO and its verification metadata."""
|
||||
|
||||
architecture: str = ""
|
||||
distro: str = ""
|
||||
gpg_status: str = ""
|
||||
iso_name: str = ""
|
||||
iso_sha256: str = ""
|
||||
iso_url: str = ""
|
||||
release: str = ""
|
||||
|
||||
|
||||
EL_DISTROS = {
|
||||
"rocky": {
|
||||
"name": "Rocky Linux",
|
||||
"base_url": "https://download.rockylinux.org/pub/rocky/",
|
||||
"gpg_key_url_pattern": "https://download.rockylinux.org/pub/rocky/RPM-GPG-KEY-Rocky-{major}",
|
||||
"sig_style": "detached", # default for new releases
|
||||
"sig_style_overrides": { # older releases lack sigs
|
||||
"8": "none",
|
||||
"9": "none",
|
||||
},
|
||||
},
|
||||
"alma": {
|
||||
"name": "AlmaLinux",
|
||||
"base_url": "https://repo.almalinux.org/almalinux/",
|
||||
"gpg_key_url_pattern": "https://repo.almalinux.org/almalinux/RPM-GPG-KEY-AlmaLinux-{major}",
|
||||
"sig_style": "inline",
|
||||
},
|
||||
}
|
||||
|
||||
# Ubuntu uses a different mirror layout: per-release directories containing the
|
||||
# ISOs alongside a SHA256SUMS file and a binary detached SHA256SUMS.gpg
|
||||
# signature made with the Ubuntu CD Image Automatic Signing Keys.
|
||||
UBUNTU = {
|
||||
"name": "Ubuntu Server",
|
||||
"base_url": "https://releases.ubuntu.com/",
|
||||
# (ubuntu_arch, per-release directory URL pattern; {ver} is the release).
|
||||
# amd64 images live on releases.ubuntu.com; other arches on cdimage.
|
||||
"sources": [
|
||||
("amd64", "https://releases.ubuntu.com/{ver}/"),
|
||||
("arm64", "https://cdimage.ubuntu.com/releases/{ver}/release/"),
|
||||
],
|
||||
"gpg_key_urls": [
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search="
|
||||
"0x843938DF228D22F7B3742BC0D94AA3F0EFE21092",
|
||||
"https://keyserver.ubuntu.com/pks/lookup?op=get&search="
|
||||
"0xC5986B4F1257FFA86632CBA746181433FBB75451",
|
||||
],
|
||||
}
|
||||
|
||||
# Debian publishes the installer 'mini.iso' under each suite's installer tree.
|
||||
# There is no signature next to the images; trust flows from the suite's
|
||||
# clear-signed InRelease file, whose SHA256 section covers the per-arch
|
||||
# SHA256SUMS file, which in turn lists mini.iso.
|
||||
DEBIAN = {
|
||||
"name": "Debian",
|
||||
"mirror": "https://deb.debian.org/debian/",
|
||||
# (suite codename, Debian version used to pick the archive signing key).
|
||||
"suites": [
|
||||
("bullseye", "11"),
|
||||
("bookworm", "12"),
|
||||
("trixie", "13"),
|
||||
],
|
||||
"archive_key_url_pattern": (
|
||||
"https://ftp-master.debian.org/keys/archive-key-{ver}.asc"
|
||||
),
|
||||
# confluent arch -> Debian arch.
|
||||
"arches": ["amd64", "arm64"],
|
||||
}
|
||||
|
||||
SUPPORTED_ARCHS = ("aarch64", "x86_64")
|
||||
REQUEST_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
# Large ISO downloads must not be capped by a total timeout; bound stalls only.
|
||||
DOWNLOAD_TIMEOUT = aiohttp.ClientTimeout(total=None, sock_connect=30, sock_read=60)
|
||||
|
||||
|
||||
# Many of the OS download sites are auto generated HTML indexes, we must parse them
|
||||
class DirectoryParser(HTMLParser):
|
||||
"""Extract href links from an nginx/apache directory listing page."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.links = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "a":
|
||||
for name, value in attrs:
|
||||
if name == "href" and value and not value.startswith(".."):
|
||||
self.links.append(value)
|
||||
|
||||
|
||||
async def fetch_url(session, url):
|
||||
"""Return the body of *url* as a string, or None on error."""
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
return await resp.text(errors="replace")
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
print(f" WARNING: could not fetch {url}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_bytes(session, url):
|
||||
"""Return the raw body of *url* as bytes, or None on error."""
|
||||
try:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
return await resp.read()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
print(f" WARNING: could not fetch {url}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_links(session, url):
|
||||
"""Return list of href values found at *url*."""
|
||||
html = await fetch_url(session, url)
|
||||
if html is None:
|
||||
return []
|
||||
parser = DirectoryParser()
|
||||
parser.feed(html)
|
||||
return parser.links
|
||||
|
||||
|
||||
def _parse_major_versions(links):
|
||||
"""Filter link list down to bare major-version directory names."""
|
||||
versions = []
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if re.fullmatch(r"\d+", name):
|
||||
versions.append(name)
|
||||
return sorted(versions, key=int)
|
||||
|
||||
|
||||
def _parse_point_releases(links, major):
|
||||
"""Filter link list down to point-release directory names for *major*."""
|
||||
releases = []
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if re.fullmatch(rf"{major}\.\d+", name):
|
||||
releases.append(name)
|
||||
return sorted(releases, key=lambda v: list(map(int, v.split("."))))
|
||||
|
||||
|
||||
def _parse_architectures(links):
|
||||
"""Filter link list down to supported architecture directory names."""
|
||||
arch_re = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]+$")
|
||||
arches = []
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if arch_re.match(name) and name in SUPPORTED_ARCHS:
|
||||
arches.append(name)
|
||||
return sorted(arches)
|
||||
|
||||
|
||||
def _parse_checksums(body):
|
||||
"""Parse a CHECKSUM file body into {filename: sha256_hex}."""
|
||||
checksums = {}
|
||||
if body is None:
|
||||
return checksums
|
||||
sha_re = re.compile(r"^SHA256\s*\((.+?)\)\s*=\s*([0-9a-fA-F]+)", re.MULTILINE)
|
||||
for m in sha_re.finditer(body):
|
||||
checksums[m.group(1)] = m.group(2).lower()
|
||||
return checksums
|
||||
|
||||
|
||||
def _parse_el_isos(links, iso_dir):
|
||||
"""Filter link list down to .iso entries, returning [(name, url), …]."""
|
||||
isos = []
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if name.lower().endswith("-dvd.iso"):
|
||||
isos.append((name, iso_dir + name))
|
||||
return sorted(isos)
|
||||
return sorted(isos)
|
||||
|
||||
|
||||
def _parse_ubuntu_versions(links):
|
||||
"""Filter link list down to two-component release dirs (e.g. '24.04')."""
|
||||
versions = set()
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if re.fullmatch(r"\d+\.\d+", name):
|
||||
versions.add(name)
|
||||
return sorted(versions, key=lambda v: list(map(int, v.split("."))))
|
||||
|
||||
|
||||
def _parse_ubuntu_point_releases(links, version):
|
||||
"""Filter link list down to point-release dirs for *version*."""
|
||||
releases = []
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if re.fullmatch(rf"{re.escape(version)}\.\d+", name):
|
||||
releases.append(name)
|
||||
return sorted(releases, key=lambda v: list(map(int, v.split("."))))
|
||||
|
||||
|
||||
def _parse_ubuntu_server_isos(links, arch, iso_dir):
|
||||
"""Filter link list down to server install .iso entries for *arch*."""
|
||||
iso_re = re.compile(
|
||||
rf"^ubuntu-.*-(?:live-)?server-{re.escape(arch)}(?:\+[a-z0-9]+)?\.iso$"
|
||||
)
|
||||
isos = set()
|
||||
for link in links:
|
||||
name = link.strip("/")
|
||||
if iso_re.match(name) and "preinstalled" not in name:
|
||||
isos.add((name, iso_dir + name))
|
||||
highestver = None
|
||||
for curriso in isos:
|
||||
distro, ver, _ = curriso[0].split("-", 2)
|
||||
if highestver is None or list(map(int, ver.split("."))) > list(
|
||||
map(int, highestver[0].split("-", 2)[1].split("."))
|
||||
):
|
||||
highestver = curriso
|
||||
if highestver:
|
||||
for curriso in list(isos):
|
||||
if curriso[0].split("-", 2)[1] != highestver[0].split("-", 2)[1]:
|
||||
isos.remove(curriso)
|
||||
return sorted(isos)
|
||||
|
||||
|
||||
def _parse_sha256sums(body):
|
||||
"""Parse a coreutils-style SHA256SUMS body into {filename: sha256_hex}."""
|
||||
checksums = {}
|
||||
if not body:
|
||||
return checksums
|
||||
for line in body.splitlines():
|
||||
m = re.match(r"^([0-9a-fA-F]{64})\s+[*]?(.+)$", line.strip())
|
||||
if m:
|
||||
checksums[m.group(2).strip()] = m.group(1).lower()
|
||||
return checksums
|
||||
|
||||
|
||||
def _parse_release_sha256(body, wanted_path):
|
||||
"""Return the SHA256 of *wanted_path* from a Debian Release/InRelease body."""
|
||||
if not body:
|
||||
return None
|
||||
for line in body.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 3 and len(parts[0]) == 64 and parts[2] == wanted_path:
|
||||
return parts[0].lower()
|
||||
return None
|
||||
|
||||
|
||||
def _parse_debian_mini_isos(checksums, images_url):
|
||||
"""Return [(relpath, url, sha256), …] for mini.iso entries in *checksums*."""
|
||||
isos = []
|
||||
for path, sha in checksums.items():
|
||||
rel = path.lstrip("./")
|
||||
if 'gtk' in rel:
|
||||
continue
|
||||
if rel.endswith("mini.iso"):
|
||||
isos.append((rel, images_url + rel, sha))
|
||||
return sorted(isos)
|
||||
|
||||
|
||||
# ── GPG verification ────────────────────────────────────────────────────────
|
||||
def _gpg_base_cmd(gnupghome):
|
||||
"""Return the common gpg argument prefix for a temporary keyring."""
|
||||
return [
|
||||
"gpg", "--batch", "--no-default-keyring",
|
||||
"--homedir", gnupghome,
|
||||
"--quiet", "--yes",
|
||||
]
|
||||
|
||||
|
||||
async def _gpg_import_key(gnupghome, key_text):
|
||||
"""Import an ASCII-armored GPG public key into a temporary keyring.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
cmd = _gpg_base_cmd(gnupghome) + ["--import"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate(input=key_text.encode())
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
async def _gpg_verify_inline(gnupghome, signed_text):
|
||||
"""Verify a PGP clear-signed message. Returns (ok, detail_str)."""
|
||||
cmd = _gpg_base_cmd(gnupghome) + ["--verify"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate(input=signed_text.encode())
|
||||
detail = stderr.decode(errors="replace").strip()
|
||||
return proc.returncode == 0, detail
|
||||
|
||||
|
||||
async def _gpg_verify_detached(gnupghome, sig_text, data_text):
|
||||
"""Verify a detached PGP signature. Returns (ok, detail_str)."""
|
||||
# gpg --verify needs file paths for detached sigs, so use temp files.
|
||||
sig_path = os.path.join(gnupghome, "sig.asc")
|
||||
data_path = os.path.join(gnupghome, "data")
|
||||
with open(sig_path, "w") as f:
|
||||
f.write(sig_text)
|
||||
with open(data_path, "w") as f:
|
||||
f.write(data_text)
|
||||
cmd = _gpg_base_cmd(gnupghome) + ["--verify", sig_path, data_path]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
detail = stderr.decode(errors="replace").strip()
|
||||
return proc.returncode == 0, detail
|
||||
|
||||
|
||||
async def _gpg_verify_detached_bytes(gnupghome, sig_bytes, data_bytes):
|
||||
"""Verify a binary detached PGP signature. Returns (ok, detail_str)."""
|
||||
sig_path = os.path.join(gnupghome, "sig.gpg")
|
||||
data_path = os.path.join(gnupghome, "data")
|
||||
with open(sig_path, "wb") as f:
|
||||
f.write(sig_bytes)
|
||||
with open(data_path, "wb") as f:
|
||||
f.write(data_bytes)
|
||||
cmd = _gpg_base_cmd(gnupghome) + ["--verify", sig_path, data_path]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
detail = stderr.decode(errors="replace").strip()
|
||||
return proc.returncode == 0, detail
|
||||
|
||||
|
||||
def _sig_style_for(distro_cfg, major):
|
||||
"""Return the effective sig_style string for a given major version."""
|
||||
overrides = distro_cfg.get("sig_style_overrides", {})
|
||||
return overrides.get(major, distro_cfg.get("sig_style", "none"))
|
||||
|
||||
|
||||
def _extract_gpg_summary(detail):
|
||||
"""Pull the one-line 'Good signature …' / 'BAD signature …' line."""
|
||||
for line in detail.splitlines():
|
||||
if "Good signature" in line or "BAD signature" in line:
|
||||
return line.strip()
|
||||
return detail.splitlines()[0].strip() if detail else ""
|
||||
|
||||
|
||||
async def get_el_dl_info(session, distro_name, distro_cfg, version=None, arch=None):
|
||||
archmap = {
|
||||
'amd64': 'x86_64',
|
||||
'arm64': 'aarch64',
|
||||
}
|
||||
arch = archmap.get(arch, arch)
|
||||
base_url = distro_cfg["base_url"]
|
||||
dlinfo = DownloadInfo(distro=distro_name)
|
||||
|
||||
if version and '.' in version:
|
||||
major = version.split('.')[0]
|
||||
minor = version.split('.')[1]
|
||||
elif version:
|
||||
major = version
|
||||
minor = None
|
||||
else:
|
||||
major = None
|
||||
minor = None
|
||||
if minor:
|
||||
raise ValueError(f"WARNING: minor version {minor} specified; only major versions are supported.")
|
||||
|
||||
|
||||
base_links = await fetch_links(session, base_url)
|
||||
majors = _parse_major_versions(base_links)
|
||||
if not majors:
|
||||
raise ValueError(f"WARNING: no major versions found for {distro_name} at {base_url}")
|
||||
if major == 'latest':
|
||||
major = majors[-1]
|
||||
if major and major in majors:
|
||||
majors = [major]
|
||||
elif major:
|
||||
raise ValueError(f"WARNING: requested major version {major} not found; using all majors.")
|
||||
|
||||
iso_index_urls = [f"{base_url}{m}/isos/" for m in majors]
|
||||
gpg_key_urls = [
|
||||
distro_cfg["gpg_key_url_pattern"].format(major=m) for m in majors
|
||||
]
|
||||
all_fetch_tasks = [fetch_links(session, u) for u in iso_index_urls] + [
|
||||
fetch_url(session, u) for u in gpg_key_urls
|
||||
]
|
||||
all_results = await asyncio.gather(*all_fetch_tasks)
|
||||
iso_index_results = all_results[: len(majors)]
|
||||
gpg_key_results = all_results[len(majors) :]
|
||||
|
||||
# Set up a temporary GPG home and import each major-version key.
|
||||
gnupghome = tempfile.mkdtemp(prefix="iso-gpg-")
|
||||
os.chmod(gnupghome, 0o700)
|
||||
try:
|
||||
key_imported = {}
|
||||
for major, key_body in zip(majors, gpg_key_results):
|
||||
if key_body and await _gpg_import_key(gnupghome, key_body):
|
||||
key_imported[major] = True
|
||||
else:
|
||||
key_imported[major] = False
|
||||
|
||||
for major, iso_links in zip(majors, iso_index_results):
|
||||
point_releases = _parse_point_releases(base_links, major)
|
||||
if minor and minor in [p.split('.')[1] for p in point_releases]:
|
||||
point_releases = [p for p in point_releases if p.split('.')[1] == minor]
|
||||
latest = point_releases[-1] if point_releases else "unknown"
|
||||
sig_style = _sig_style_for(distro_cfg, major)
|
||||
|
||||
dlinfo.release = latest
|
||||
|
||||
arches = _parse_architectures(iso_links)
|
||||
if not arches:
|
||||
raise ValueError("No ISO architectures found.")
|
||||
if arch and arch in arches:
|
||||
arches = [arch]
|
||||
elif arch:
|
||||
raise ValueError(f"Requested architecture {arch} not found for {distro_name} {major}")
|
||||
|
||||
arch_tasks = []
|
||||
for arch in arches:
|
||||
iso_dir = f"{base_url}{major}/isos/{arch}/"
|
||||
checksum_url = f"{iso_dir}CHECKSUM"
|
||||
tasks = [
|
||||
fetch_links(session, iso_dir),
|
||||
fetch_url(session, checksum_url),
|
||||
]
|
||||
if sig_style == "detached":
|
||||
tasks.append(fetch_url(session, f"{iso_dir}CHECKSUM.asc"))
|
||||
arch_tasks.append(asyncio.gather(*tasks))
|
||||
arch_results = await asyncio.gather(*arch_tasks)
|
||||
|
||||
for arch, results in zip(arches, arch_results):
|
||||
iso_dir = f"{base_url}{major}/isos/{arch}/"
|
||||
dir_links = results[0]
|
||||
checksum_body = results[1]
|
||||
sig_body = results[2] if len(results) > 2 else None
|
||||
|
||||
checksums = _parse_checksums(checksum_body)
|
||||
isos = _parse_el_isos(dir_links, iso_dir)
|
||||
|
||||
# ── GPG verification of the CHECKSUM file ────────────
|
||||
if sig_style == "none" or not key_imported.get(major):
|
||||
if sig_style == "none":
|
||||
dlinfo.gpg_status = "unpublished"
|
||||
gpg_status = "UNSIGNED (no signature published)"
|
||||
else:
|
||||
dlinfo.gpg_status = "unavailable"
|
||||
elif sig_style == "inline" and checksum_body:
|
||||
ok, detail = await _gpg_verify_inline(
|
||||
gnupghome, checksum_body
|
||||
)
|
||||
summary = _extract_gpg_summary(detail)
|
||||
if not ok:
|
||||
raise ValueError(f"GPG verification failed for {distro_name} {major} {arch}: {summary}")
|
||||
dlinfo.gpg_status = "verified"
|
||||
|
||||
elif sig_style == "detached" and checksum_body and sig_body:
|
||||
ok, detail = await _gpg_verify_detached(
|
||||
gnupghome, sig_body, checksum_body
|
||||
)
|
||||
summary = _extract_gpg_summary(detail)
|
||||
if not ok:
|
||||
raise ValueError(f"GPG verification failed for {distro_name} {major} {arch}: {summary}")
|
||||
dlinfo.gpg_status = "verified"
|
||||
else:
|
||||
dlinfo.gpg_status = "missing"
|
||||
dlinfo.architecture = arch
|
||||
if not isos:
|
||||
raise ValueError(f"No ISOs found for {distro_name} {major} {arch}")
|
||||
if len(isos) > 1:
|
||||
raise ValueError(f"Multiple ISOs found for {distro_name} {major} {arch}; expected only one.")
|
||||
iso_name, iso_url = isos[0]
|
||||
dlinfo.iso_name = iso_name
|
||||
dlinfo.iso_url = iso_url
|
||||
dlinfo.iso_sha256 = checksums.get(iso_name, "n/a")
|
||||
return dlinfo
|
||||
finally:
|
||||
shutil.rmtree(gnupghome, ignore_errors=True)
|
||||
|
||||
|
||||
async def get_ubuntu_dlinfo(session, cfg, version=None, arch=None):
|
||||
archmap = {
|
||||
'x86_64': 'amd64',
|
||||
'aarch64': 'arm64',
|
||||
}
|
||||
dlinfo = DownloadInfo(distro=cfg["name"])
|
||||
archextra = ''
|
||||
if '+' in arch:
|
||||
arch, archextra = arch.split('+', 1)
|
||||
archextra = '+' + archextra
|
||||
arch = archmap.get(arch, arch)
|
||||
|
||||
name = cfg["name"]
|
||||
base_url = cfg["base_url"]
|
||||
|
||||
base_links = await fetch_links(session, base_url)
|
||||
versions = _parse_ubuntu_versions(base_links)
|
||||
if not versions:
|
||||
raise ValueError("No versions found.")
|
||||
if version and version in versions:
|
||||
versions = [version]
|
||||
elif version:
|
||||
raise ValueError(f"Requested version {version} not found")
|
||||
|
||||
gnupghome = tempfile.mkdtemp(prefix="iso-gpg-")
|
||||
os.chmod(gnupghome, 0o700)
|
||||
try:
|
||||
key_bodies = await asyncio.gather(
|
||||
*[fetch_url(session, u) for u in cfg["gpg_key_urls"]]
|
||||
)
|
||||
key_imported = False
|
||||
for key_body in key_bodies:
|
||||
if key_body and await _gpg_import_key(gnupghome, key_body):
|
||||
key_imported = True
|
||||
|
||||
fetch_specs = []
|
||||
for ver in versions:
|
||||
for iterarch, url_pattern in cfg["sources"]:
|
||||
fetch_specs.append((ver, iterarch, url_pattern.format(ver=ver)))
|
||||
fetch_tasks = [
|
||||
asyncio.gather(
|
||||
fetch_links(session, iso_dir),
|
||||
fetch_bytes(session, f"{iso_dir}SHA256SUMS"),
|
||||
fetch_bytes(session, f"{iso_dir}SHA256SUMS.gpg"),
|
||||
)
|
||||
for _, _, iso_dir in fetch_specs
|
||||
]
|
||||
fetch_results = await asyncio.gather(*fetch_tasks)
|
||||
|
||||
by_version = {}
|
||||
for (ver, iterarch, iso_dir), res in zip(fetch_specs, fetch_results):
|
||||
by_version.setdefault(ver, []).append((iterarch, iso_dir, res))
|
||||
|
||||
for ver in versions:
|
||||
point_releases = _parse_ubuntu_point_releases(base_links, ver)
|
||||
latest = point_releases[-1] if point_releases else ver
|
||||
dlinfo.release = latest
|
||||
found = False
|
||||
for iterarch, iso_dir, results in by_version[ver]:
|
||||
if arch and iterarch != arch:
|
||||
continue
|
||||
dir_links, sums_bytes, sig_bytes = results
|
||||
isos = _parse_ubuntu_server_isos(dir_links, iterarch, iso_dir)
|
||||
if not isos:
|
||||
continue
|
||||
isos = [(name, url) for name, url in isos if name.endswith(f"-{iterarch}{archextra}.iso")]
|
||||
if found:
|
||||
raise ValueError(f"Multiple ISOs found for {name} {ver} {iterarch}; expected only one.")
|
||||
found = True
|
||||
|
||||
sums_text = (
|
||||
sums_bytes.decode("ascii", "replace") if sums_bytes else None
|
||||
)
|
||||
checksums = _parse_sha256sums(sums_text)
|
||||
|
||||
# ── GPG verification of the SHA256SUMS file ──────────
|
||||
if not key_imported:
|
||||
dlinfo.gpg_status = "unavailable"
|
||||
elif sums_bytes and sig_bytes:
|
||||
ok, detail = await _gpg_verify_detached_bytes(
|
||||
gnupghome, sig_bytes, sums_bytes
|
||||
)
|
||||
summary = _extract_gpg_summary(detail)
|
||||
if not ok:
|
||||
raise ValueError(f"GPG verification failed for {name} {ver} {iterarch}: {summary}")
|
||||
dlinfo.gpg_status = "verified"
|
||||
else:
|
||||
dlinfo.gpg_status = "missing"
|
||||
dlinfo.architecture = iterarch
|
||||
if len(isos) > 1:
|
||||
print(repr(isos))
|
||||
raise ValueError(f"Multiple ISOs found for {name} {ver} {iterarch}; expected only one.")
|
||||
iso_name, iso_url = isos[0]
|
||||
dlinfo.iso_name = iso_name
|
||||
dlinfo.iso_url = iso_url
|
||||
dlinfo.iso_sha256 = checksums.get(iso_name, "n/a")
|
||||
return dlinfo
|
||||
finally:
|
||||
shutil.rmtree(gnupghome, ignore_errors=True)
|
||||
|
||||
|
||||
async def get_debian_dlinfo(session, cfg, version=None, arch=None):
|
||||
archmap = {
|
||||
'x86_64': 'amd64',
|
||||
'aarch64': 'arm64',
|
||||
}
|
||||
dlinfo = DownloadInfo(distro='debian')
|
||||
arch = archmap.get(arch, arch)
|
||||
name = cfg["name"]
|
||||
mirror = cfg["mirror"]
|
||||
|
||||
gnupghome = tempfile.mkdtemp(prefix="iso-gpg-")
|
||||
os.chmod(gnupghome, 0o700)
|
||||
try:
|
||||
arches = cfg["arches"]
|
||||
if arch:
|
||||
arches = [x for x in arches if x == arch]
|
||||
if not arches:
|
||||
raise ValueError(f"Requested architecture {arch} not found for {name}")
|
||||
if version:
|
||||
if version == 'latest':
|
||||
version = cfg["suites"][-1][1]
|
||||
for codename, ver in cfg["suites"]:
|
||||
if version and version != ver and version != codename:
|
||||
continue
|
||||
key_url = cfg["archive_key_url_pattern"].format(ver=ver)
|
||||
inrelease_url = f"{mirror}dists/{codename}/InRelease"
|
||||
image_dirs = [
|
||||
f"{mirror}dists/{codename}/main/installer-{da}/current/images/"
|
||||
for da in arches
|
||||
]
|
||||
|
||||
# Fetch the signing key, InRelease and every arch SHA256SUMS at once.
|
||||
results = await asyncio.gather(
|
||||
fetch_url(session, key_url),
|
||||
fetch_url(session, inrelease_url),
|
||||
*[fetch_bytes(session, f"{d}SHA256SUMS") for d in image_dirs],
|
||||
)
|
||||
key_body, inrelease = results[0], results[1]
|
||||
sums_bytes_list = results[2:]
|
||||
|
||||
# Import the release archive key and verify the InRelease signature.
|
||||
# Debian signs InRelease with several keys (current plus upcoming
|
||||
# releases); a Good signature from the imported archive key is
|
||||
# sufficient even though gpg exits non-zero on the unknown ones.
|
||||
key_imported = bool(key_body) and await _gpg_import_key(
|
||||
gnupghome, key_body
|
||||
)
|
||||
if key_imported and inrelease:
|
||||
_, detail = await _gpg_verify_inline(gnupghome, inrelease)
|
||||
sig_ok = (
|
||||
"Good signature" in detail and "BAD signature" not in detail
|
||||
)
|
||||
else:
|
||||
sig_ok, detail = False, ""
|
||||
|
||||
for deb_arch, images_url, sums_bytes in zip(
|
||||
arches, image_dirs, sums_bytes_list
|
||||
):
|
||||
if arch and deb_arch != arch:
|
||||
continue
|
||||
sums_text = (
|
||||
sums_bytes.decode("ascii", "replace") if sums_bytes else None
|
||||
)
|
||||
checksums = _parse_sha256sums(sums_text)
|
||||
isos = _parse_debian_mini_isos(checksums, images_url)
|
||||
if not isos:
|
||||
raise ValueError(f"No mini.iso found for {name} {ver} {deb_arch}")
|
||||
if len(isos) > 1:
|
||||
raise ValueError(
|
||||
f"Multiple mini.iso found for {name} {ver} {deb_arch}; expected only one."
|
||||
)
|
||||
newname = f'debian-{ver}-{deb_arch}-mini.iso'
|
||||
|
||||
dlinfo.release = ver
|
||||
dlinfo.architecture = deb_arch
|
||||
|
||||
wanted = f"main/installer-{deb_arch}/current/images/SHA256SUMS"
|
||||
expected = _parse_release_sha256(inrelease, wanted)
|
||||
actual = (
|
||||
hashlib.sha256(sums_bytes).hexdigest() if sums_bytes else None
|
||||
)
|
||||
if not key_imported:
|
||||
dlinfo.gpg_status = "unavailable"
|
||||
elif not sig_ok:
|
||||
raise ValueError(f"GPG verification failed for {name} {ver} {deb_arch}: {_extract_gpg_summary(detail)}")
|
||||
elif expected and actual and expected == actual:
|
||||
dlinfo.gpg_status = "verified"
|
||||
else:
|
||||
raise ValueError(f"GPG verification failed for {name} {ver} {deb_arch}: SHA256SUMS mismatch (expected {expected}, got {actual})")
|
||||
dlinfo.iso_name = newname
|
||||
dlinfo.iso_url = isos[0][1]
|
||||
dlinfo.iso_sha256 = isos[0][2]
|
||||
|
||||
return dlinfo
|
||||
finally:
|
||||
shutil.rmtree(gnupghome, ignore_errors=True)
|
||||
|
||||
|
||||
async def get_dlinfo(distspec):
|
||||
distparts = distspec.split("-", 2)
|
||||
if len(distparts) < 3:
|
||||
raise ValueError('Unable to parse distribution specifier; expected format: "<distro>-<version>-<arch>"')
|
||||
distname = distparts[0]
|
||||
async with aiohttp.ClientSession(timeout=REQUEST_TIMEOUT) as session:
|
||||
if distname in EL_DISTROS:
|
||||
return await get_el_dl_info(
|
||||
session,
|
||||
distname,
|
||||
EL_DISTROS[distname],
|
||||
distparts[1],
|
||||
distparts[2],
|
||||
)
|
||||
elif distname == "ubuntu":
|
||||
return await get_ubuntu_dlinfo(
|
||||
session, UBUNTU, distparts[1], distparts[2]
|
||||
)
|
||||
elif distname == "debian":
|
||||
return await get_debian_dlinfo(
|
||||
session, DEBIAN, distparts[1], distparts[2]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported distribution: {distname}")
|
||||
|
||||
async def download_dist(distspec, dest_dir, progress_callback=None, dlinfo=None):
|
||||
if dlinfo is None:
|
||||
dlinfo = await get_dlinfo(distspec)
|
||||
if not dlinfo.iso_url:
|
||||
raise ValueError(f"No ISO URL found for {distspec}")
|
||||
iso_path = os.path.join(dest_dir, dlinfo.iso_name)
|
||||
async with aiohttp.ClientSession(timeout=DOWNLOAD_TIMEOUT) as session:
|
||||
async with session.get(dlinfo.iso_url) as resp:
|
||||
totalsize = int(resp.headers.get('Content-Length', 0))
|
||||
if resp.status != 200:
|
||||
raise ValueError(f"Failed to download ISO: {resp.status}")
|
||||
currsize = 0
|
||||
with open(iso_path, "wb") as f:
|
||||
while True:
|
||||
chunk = await resp.content.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
currsize += len(chunk)
|
||||
f.write(chunk)
|
||||
if progress_callback:
|
||||
await progress_callback(currsize, totalsize)
|
||||
return iso_path
|
||||
|
||||
|
||||
def _format_bytes(num):
|
||||
"""Return *num* bytes as a human-friendly string (e.g. '1.5 GiB')."""
|
||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
||||
if abs(num) < 1024 or unit == "TiB":
|
||||
return f"{num:.1f} {unit}" if unit != "B" else f"{num} B"
|
||||
num /= 1024
|
||||
|
||||
|
||||
def _format_duration(seconds):
|
||||
"""Return *seconds* as a compact 'HH:MM:SS' / 'MM:SS' string."""
|
||||
seconds = int(seconds)
|
||||
h, rem = divmod(seconds, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
async def print_progress(currsize, totalsize):
|
||||
now = time.monotonic()
|
||||
# Reset timing when a new download begins (first call or size went backwards).
|
||||
if getattr(print_progress, "_start", None) is None or currsize < getattr(
|
||||
print_progress, "_last", 0
|
||||
):
|
||||
print_progress._start = now
|
||||
print_progress._start_size = currsize
|
||||
print_progress._last = currsize
|
||||
|
||||
elapsed = now - print_progress._start
|
||||
downloaded = currsize - print_progress._start_size
|
||||
rate = downloaded / elapsed if elapsed > 0 else 0
|
||||
speed = f" @ {_format_bytes(rate)}/s" if rate > 0 else ""
|
||||
|
||||
if not totalsize:
|
||||
print(f"\r\033[KDownloaded {_format_bytes(currsize)}{speed}", end='', flush=True)
|
||||
return
|
||||
percent = (currsize / totalsize * 100)
|
||||
if rate > 0:
|
||||
eta = f", ETA {_format_duration((totalsize - currsize) / rate)}"
|
||||
else:
|
||||
eta = ""
|
||||
print(f"\r\033[KDownloaded {_format_bytes(currsize)} of {_format_bytes(totalsize)} ({percent:.2f}%){speed}{eta}", end='', flush=True)
|
||||
if currsize >= totalsize:
|
||||
print_progress._start = None # reset for the next download
|
||||
print() # New line after completion
|
||||
|
||||
async def async_main():
|
||||
from pprint import pprint
|
||||
|
||||
distname = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
if not distname:
|
||||
print("Usage: rocky_iso_urls.py <distro_name>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
dlinfo = await get_dlinfo(distname)
|
||||
pprint(dlinfo)
|
||||
tmpdir = tempfile.mkdtemp(prefix="iso-download-")
|
||||
|
||||
iso_path = await download_dist(distname, tmpdir, print_progress, dlinfo=dlinfo)
|
||||
print(f"\nDownloaded ISO to: {iso_path}")
|
||||
|
||||
|
||||
def main():
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user