michael.hudson@canonical.com f0c7c28618
isobuilder: pass ignore_dangling_symlinks=True when copying apt config
ubuntukylin's /etc/apt/trusted.gpg.d/ubuntukylin-archive-keyring.gpg
contains a symlink to
"/usr/share/keyrings/ubuntukylin-archive-keyring.gpg" as an absolute
path. This obviously doesn't work when not chrooted into the chroot but
we don't need to copy it over to the apt config used to build the pool
as no package from any archive signed by this key is going to be
included in the pool...
2026-02-16 21:24:46 +13:00

110 lines
3.8 KiB
Python

import dataclasses
import os
import pathlib
import shutil
import subprocess
from typing import Iterator
@dataclasses.dataclass
class PackageInfo:
package: str
filename: str
architecture: str
version: str
@property
def spec(self) -> str:
return f"{self.package}:{self.architecture}={self.version}"
def check_proc(proc, ok_codes=(0,)) -> None:
proc.wait()
if proc.returncode not in ok_codes:
raise Exception(f"{proc} failed")
class AptStateManager:
"""Maintain and use an apt state directory to access package info and debs."""
def __init__(self, logger, series: str, apt_dir: pathlib.Path):
self.logger = logger
self.series = series
self.apt_root = apt_dir.joinpath("root")
self.apt_conf_path = apt_dir.joinpath("apt.conf")
def _apt_env(self) -> dict[str, str]:
return dict(os.environ, APT_CONFIG=str(self.apt_conf_path))
def setup(self, chroot: pathlib.Path):
"""Set up the manager by copying the apt configuration from `chroot`."""
for path in "etc/apt", "var/lib/apt":
tgt = self.apt_root.joinpath(path)
tgt.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(chroot.joinpath(path), tgt, ignore_dangling_symlinks=True)
self.apt_conf_path.write_text(f'Dir "{self.apt_root}/"; \n')
with self.logger.logged("updating apt indices"):
self.logger.run(["apt-get", "update"], env=self._apt_env())
def show(self, pkgs: list[str]) -> Iterator[PackageInfo]:
"""Return information about the binary packages named by `pkgs`.
Parses apt-cache output, which uses RFC822-like format: field names
followed by ": " and values, with multi-line values indented with
leading whitespace. We skip continuation lines (starting with space)
since PackageInfo only needs single-line fields.
The `fields` set (derived from PackageInfo's dataclass fields) acts as
a filter - we only extract fields we care about, ignoring others like
Description.
"""
proc = subprocess.Popen(
["apt-cache", "-o", "APT::Cache::AllVersions=0", "show"] + pkgs,
stdout=subprocess.PIPE,
encoding="utf-8",
env=self._apt_env(),
)
assert proc.stdout is not None
fields = {f.name for f in dataclasses.fields(PackageInfo)}
params: dict[str, str] = {}
for line in proc.stdout:
if line == "\n":
yield PackageInfo(**params)
params = {}
continue
if line.startswith(" "):
continue
field, value = line.split(": ", 1)
field = field.lower()
if field in fields:
params[field] = value.strip()
check_proc(proc)
if params:
yield PackageInfo(**params)
def download(self, rootdir: pathlib.Path, pkg_info: PackageInfo):
"""Download the package specified by `pkg_info` under `rootdir`.
The package is saved to the same path under `rootdir` as it is
at in the archive it comes from.
"""
target_dir = rootdir.joinpath(pkg_info.filename).parent
target_dir.mkdir(parents=True, exist_ok=True)
self.logger.run(
["apt-get", "download", pkg_info.spec],
cwd=target_dir,
check=True,
env=self._apt_env(),
)
def in_release_path(self) -> pathlib.Path:
"""Return the path to the InRelease file.
This assumes exactly one InRelease file matches the pattern.
Will raise ValueError if there are 0 or multiple matches.
"""
[path] = self.apt_root.joinpath("var/lib/apt/lists").glob(
f"*_dists_{self.series}_InRelease"
)
return path