## Copyright (C) 2017 Red Hat, Inc.# SPDX-License-Identifier: LGPL-2.1-or-laterimport loggingimport osimport refrom collections.abc import Mappingfrom testlib import MachineCaseclass PackageCase(MachineCase):def setUp(self) -> None:super().setUp()self.repo_dir = os.path.join(self.vm_tmpdir, "repo")if self.machine.ostree_image:logging.warning("PackageCase: OSTree images can't install additional packages")return# PackageKit 1.3.4 no longer installs pkcon by defaultself.packagekit_cmd = self.machine.execute("command -v pkcon pkgcli").strip().split()[0]# expected backend; hardcode this on image names to check the auto-detectionif self.machine.image.startswith("debian") or self.machine.image.startswith("ubuntu"):self.backend = "apt"self.primary_arch = "all"self.secondary_arch = "amd64"elif re.match(r"(centos|rhel)-(8|9|10).*", self.machine.image):self.backend = "dnf4"self.primary_arch = "noarch"self.secondary_arch = "x86_64"elif self.machine.image.startswith(("fedora", "rhel-", "centos-")):self.backend = "dnf5"self.primary_arch = "noarch"self.secondary_arch = "x86_64"elif "suse" in self.machine.image:self.backend = "zypper"self.primary_arch = "noarch"self.secondary_arch = "x86_64"elif self.machine.image == "arch":self.backend = "alpm"self.primary_arch = "any"self.secondary_arch = "x86_64"else:raise NotImplementedError("unknown image " + self.machine.image)if "debian" in self.image or "ubuntu" in self.image:# PackageKit refuses to work when offline, and main interface is not managed by NM on these imagesself.machine.execute("nmcli con add type dummy con-name fake ifname fake0 ip4 1.2.3.4/24 gw4 1.2.3.1")self.addCleanup(self.machine.execute, "nmcli con delete fake")# Stop Packagekit to not run into locking issues with aptself.machine.execute("systemctl stop packagekit; systemctl reset-failed packagekit || true")self.addCleanup(self.machine.execute, "systemctl stop packagekit; systemctl reset-failed packagekit || true")# disable all existing repositories to avoid hitting the networkif self.backend == "apt":self.restore_dir("/var/lib/apt", reboot_safe=True)self.restore_dir("/var/cache/apt", reboot_safe=True)self.restore_dir("/etc/apt", reboot_safe=True)self.machine.execute("echo > /etc/apt/sources.list; rm -f /etc/apt/sources.list.d/*; apt-get clean; apt-get update")elif self.backend == "alpm":self.restore_dir("/var/lib/pacman", reboot_safe=True)self.restore_dir("/var/cache/pacman", reboot_safe=True)self.restore_dir("/etc/pacman.d", reboot_safe=True)self.restore_dir("/var/lib/PackageKit/alpm", reboot_safe=True)self.restore_file("/etc/pacman.conf")self.restore_file("/etc/pacman.d/mirrorlist")self.restore_file("/usr/share/libalpm/hooks/90-packagekit-refresh.hook")self.machine.execute("rm -r /var/lib/pacman/sync/*")self.machine.execute("rm /etc/pacman.conf /etc/pacman.d/mirrorlist ""/usr/share/libalpm/hooks/90-packagekit-refresh.hook")# Drop alpm state directory as it interferes with running offlineself.machine.execute("test -d /var/lib/PackageKit/alpm && rm -r /var/lib/PackageKit/alpm || true")# Clean up possible leftover lockfileself.machine.execute("""if [ -f /var/lib/pacman/db.lck ]; thenfuser -k /var/lib/pacman/db.lck || true;rm /var/lib/pacman/db.lck;fi""")# Initial config for installationempty_repo_dir = '/var/lib/cockpittest/empty'config = f"""[options]Architecture = autoHoldPkg = pacman glibcSigLevel = Never[empty]SigLevel = NeverServer = file://"""# HACK: Setup empty repo for packagekitself.machine.execute(f"mkdir -p || true")self.machine.execute(f"repo-add /empty.db.tar.gz")self.machine.write("/etc/pacman.conf", config)self.machine.execute("pacman -Sy")else:self.restore_dir("/etc/yum.repos.d", reboot_safe=True)self.restore_dir("/var/cache/dnf", reboot_safe=True)self.restore_dir("/usr/share/dnf5/repos.d", reboot_safe=True)self.machine.execute("rm -rf /etc/yum.repos.d/* /var/cache/dnf/* /usr/share/dnf5/repos.d/*")# have PackageKit start from a clean slateself.machine.execute("systemctl stop packagekit")self.machine.execute("systemctl kill --signal=SIGKILL packagekit || true; rm -rf /var/cache/PackageKit")self.machine.execute("systemctl reset-failed packagekit || true")self.restore_file("/var/lib/PackageKit/transactions.db")if self.image in ["debian-trixie", "debian-testing"]:# PackageKit tries to resolve some DNS names, but our test VM is offline;# temporarily disable the name server to fail quicklyself.machine.execute("mv /etc/resolv.conf /etc/resolv.conf.test")self.addCleanup(self.machine.execute, "mv /etc/resolv.conf.test /etc/resolv.conf")# reset automatic updatesif self.backend == 'dnf4':self.restore_file("/etc/dnf/automatic.conf")self.machine.execute("systemctl disable --now dnf-automatic dnf-automatic-install ""dnf-automatic.service dnf-automatic-install.timer")self.machine.execute("rm -r /etc/systemd/system/dnf-automatic* && systemctl daemon-reload || true")if self.backend == 'dnf5':self.restore_file("/etc/dnf/dnf5-plugins/automatic.conf")self.addCleanup(self.machine.execute, "systemctl disable --now dnf5-automatic.timer 2>/dev/null || true")self.addCleanup(self.machine.execute,"rm -r /etc/systemd/system/dnf5-automatic*.d && systemctl daemon-reload || true")self.updateInfo: dict[tuple[str, str, str], Mapping[str, str | list[str]]] = {}# HACK: kpatch check sometimes complains that we don't set up a full repo in unrelated testsself.allow_browser_errors("Could not determine kpatch packages:.*repodata updates was not complete")## Helper functions for creating packages/repository#def createPackage(self, name: str, version: str, release: str, *, install: bool = False,postinst: str | None = None, depends: str = "",content: Mapping[str, Mapping[str, str] | str] | None = None,arch: str | None = None, provides: str | None = None,conflicts: str | None = None,obsoletes: str | None = None,**updateinfo: str | list[str]) -> None:"""Create a dummy package in repo_dir on self.machineIf install is True, install the package. Otherwise, update the packageindex in repo_dir."""if self.backend == "apt":self.createDeb(name, version + '-' + release, depends, postinst, install=install, content=content,arch=arch, provides=provides, conflicts=conflicts)elif self.backend == "alpm":self.createPacmanPkg(name, version, release, depends, postinst, install=install, content=content,arch=arch, provides=provides, conflicts=conflicts)else:self.createRpm(name, version, release, depends, postinst, install=install, content=content,arch=arch, provides=provides, conflicts=conflicts, obsoletes=obsoletes)if updateinfo:self.updateInfo[name, version, release] = updateinfodef createDeb(self, name: str, version: str, depends: str, postinst: str | None = None, *, install: bool,content: Mapping[str, Mapping[str, str] | str] | None = None,arch: str | None = None, provides: str | None = None,conflicts: str | None = None) -> None:"""Create a dummy deb in repo_dir on self.machineIf install is True, install the package. Otherwise, update the packageindex in repo_dir."""m = self.machineextra_fields = ""if provides:extra_fields += f"Provides: \n"if conflicts:extra_fields += f"Conflicts: \n"if arch is None:arch = self.primary_archdeb = f"/__.deb"if postinst:postinstcode = f"printf '#!/bin/sh\n' > /tmp/b/DEBIAN/postinst; chmod 755 /tmp/b/DEBIAN/postinst"else:postinstcode = ''if content is not None:for path, data in content.items():dest = "/tmp/b/" + pathm.execute(f"mkdir -p ''")if isinstance(data, Mapping):m.execute(f"cp '' ''")else:m.write(dest, data)m.execute(f"mkdir -p ")m.write("/tmp/b/DEBIAN/control", f"""Package:Version:Priority: optionalSection: testMaintainer: fooDepends:Architecture:Description: dummy""")cmd = f"""set -etouch /tmp/b/stamp--dpkg -b /tmp/brm -r /tmp/b"""if install:cmd += "dpkg -i " + debm.execute(cmd)self.addCleanup(m.execute, f"dpkg -P --force-depends --force-remove-reinstreq 2>/dev/null || true")def setupLocalRPMKey(self) -> None:"""Fedora requires signed RPMs, set up local key"""key = "~/.config/rpm/rpmbuild-*.asc"self.machine.execute(f"if [ -x /usr/lib/rpm/rpm-setup-autosign ] && ! [ -f ]; then "" /usr/lib/rpm/rpm-setup-autosign; "f" rpmkeys --import ; ""fi")def createRpm(self, name: str, version: str, release: str, requires: str, post: str | None = None, *,install: bool,content: Mapping[str, Mapping[str, str] | str] | None = None,arch: str | None = None,provides: str | None = None,obsoletes: str | None = None,conflicts: str | None = None) -> None:"""Create a dummy rpm in repo_dir on self.machineIf install is True, install the package. Otherwise, update the packageindex in repo_dir."""self.setupLocalRPMKey()if post:postcode = '\n%%post\n' + postelse:postcode = ''if requires:requires = f"Requires: \n"conflicts = f"Conflicts: \n" if conflicts is not None else ""provides = f"Provides: \n" if provides is not None else ""obsoletes = f"Obsoletes: \n" if obsoletes is not None else ""if arch is None:arch = self.primary_archinstallcmds = f"touch $RPM_BUILD_ROOT/stamp---\n"installedfiles = f"/stamp---\n"if content is not None:for path, data in content.items():installcmds += f'mkdir -p $(dirname "$RPM_BUILD_ROOT/")\n'if isinstance(data, Mapping):installcmds += f"cp \"$RPM_BUILD_ROOT/\"\n"else:installcmds += f'cat >"$RPM_BUILD_ROOT/" <<\'EOF\'\n' + data + '\nEOF\n'installedfiles += f"\n"architecture = ""if arch == self.primary_arch:architecture = f"BuildArch: "spec = f"""Summary: dummyName:Version:Release:License: BSD%define _build_id_links none%%install%%descriptionTest package.%%files"""self.machine.write("/tmp/spec", spec)cmd = """rpmbuild --quiet -bb /tmp/specmkdir -p {0}cp ~/rpmbuild/RPMS/{4}/*.rpm {0}rm -rf ~/rpmbuild"""if install:cmd += "rpm -i {0}/{1}-{2}-{3}.*.rpm"self.machine.execute(cmd.format(self.repo_dir, name, version, release, arch))self.addCleanup(self.machine.execute, f"rpm -e --nodeps 2>/dev/null || true")def createPacmanPkg(self, name: str, version: str, release: str, requires: str, postinst: str | None = None, *,install: bool,content: Mapping[str, Mapping[str, str] | str] | None = None,arch: str | None = None,provides: str | None = None,conflicts: str | None = None) -> None:"""Create a dummy pacman package in repo_dir on self.machineIf install is True, install the package. Otherwise, update the packageindex in repo_dir."""if arch is None:arch = 'any'conflicts = f'conflicts=("")' if conflicts else ''provides = f'provides=("")' if provides else ''sources = ""installcmds = 'package() {\n'if content is not None:sources = "source=("files = 0for path, data in content.items():p = os.path.dirname(path)installcmds += f'mkdir -p $pkgdir\n'if isinstance(data, Mapping):dpath = data["path"]file = os.path.basename(dpath)sources += filefiles += 1# TODO: hardcoded /tmpself.machine.execute(f'cp /tmp/')installcmds += f'cp $pkgdir\n'else:installcmds += f'cat >"$pkgdir" <<\'EOF\'\n' + data + '\nEOF\n'sources += ")"# Always stamp a fileinstallcmds += f"touch $pkgdir/stamp---\n"installcmds += '}'pkgbuild = f"""pkgname=pkgver=pkgdesc="dummy "pkgrel=arch=()depends=()options=(!debug)"""if postinst:postinstcode = f"""post_install() {{}}post_upgrade() {{post_install $*}}"""self.machine.write(f"/tmp/.install", postinstcode)pkgbuild += f"\ninstall=.install\n"self.machine.write("/tmp/PKGBUILD", pkgbuild)cmd = """cd /tmp/su builder -c "makepkg --cleanbuild --clean --force --nodeps --skipinteg --noconfirm""""if install:cmd += f"pacman -U --overwrite '*' --noconfirm ---.pkg.tar.zst\n"cmd += f"mkdir -p \n"cmd += f"mv *.pkg.tar.zst \n"# Clean up packaging filescmd += "rm PKGBUILD\n"if postinst:cmd += f"rm /tmp/.install"self.machine.execute(cmd)self.addCleanup(self.machine.execute, f"pacman -Rdd --noconfirm 2>/dev/null || true")def createAptChangelogs(self) -> None:# apt metadata has no formal field for bugs/CVEs, they are parsed from the changelogfor ((pkg, ver, rel), info) in self.updateInfo.items():changes = info.get("changes", "some changes")assert isinstance(changes, str)if info.get("bugs"):assert isinstance(info["bugs"], list)changes += f" (Closes: )"if info.get("cves"):assert isinstance(info["cves"], list)changes += "\n * " + ", ".join(info["cves"])if info.get("invalid_cves"):assert isinstance(info["invalid_cves"], list)changes += "\n * " + ", ".join(info["invalid_cves"])path = f"/changelogs///_-"contents = f""" (-) unstable; urgency=medium*-- Joe Developer <joe@example.com> Wed, 31 May 2017 14:52:25 +0200"""self.machine.execute(f"mkdir -p $(dirname ); echo '' > ")def createYumUpdateInfo(self) -> str:xml = '<?xml version="1.0" encoding="UTF-8"?>\n<updates>\n'for ((pkg, ver, rel), info) in self.updateInfo.items():refs = ""for b in info.get("bugs", []):refs += f' <reference href="https://bugs.example.com?bug=" id="" title="Bug# Description" type="bugzilla"/>\n'for c in info.get("cves", []):refs += f' <reference href="https://www.cve.org/CVERecord?id=" id="" title="" type="cve"/>\n'if info.get("securitySeverity"):refs += ' <reference href="https://access.redhat.com/security/updates/classification/#{0}" id="" title="" type="other"/>\n'.format(info["securitySeverity"])for e in info.get("errata", []):refs += f' <reference href="https://access.redhat.com/errata/" id="" title="" type="self"/>\n'# Inject invalid urlsfor i in info.get("invalid_cves", []):refs += f' <reference href="" onmouseover="alert(document.domain);" id="invalid" title="invalid" type="cve"/>\n'xml += """ <update from="test@example.com" status="stable" type="{severity}" version="2.0"><id>UPDATE-{pkg}-{ver}-{rel}</id><title>{pkg} {ver}-{rel} update</title><issued date="2017-01-01 12:34:56"/><description>{desc}</description><references>{refs}</references><pkglist><collection short="0815"><package name="{pkg}" version="{ver}" release="{rel}" epoch="0" arch="noarch"><filename>{pkg}-{ver}-{rel}.noarch.rpm</filename></package></collection></pkglist></update>""".format(pkg=pkg, ver=ver, rel=rel, refs=refs,desc=info.get("changes", ""), severity=info.get("severity", "bugfix"))xml += '</updates>\n'return xmldef addPackageSet(self, name: str) -> None:self.machine.execute(f"mkdir -p ; cp /var/lib/package-sets//* ")def enableRepo(self, *, refresh_repo: bool = False) -> None:if self.backend == "apt":self.createAptChangelogs()self.machine.execute(f"""echo 'deb [trusted=yes] file:// /' > /etc/apt/sources.list.d/test.listcd ; apt-ftparchive packages . > Packagesxz -c Packages > Packages.xzO=$(apt-ftparchive -o APT::FTPArchive::Release::Origin=cockpittest release .); echo "$O" > Releaseecho 'Changelogs: http://localhost:12345/changelogs/@CHANGEPATH@' >> Release""")pid = self.machine.spawn(f"cd ; exec python3 -m http.server 12345", "changelog")# pid will not be present for rebooting testsself.addCleanup(self.machine.execute, "kill %i || true" % pid)self.machine.wait_for_cockpit_running(port=12345) # wait for changelog HTTP server to start upelif self.backend == "alpm":self.machine.execute(f"""cdrepo-add /testrepo.db.tar.gz *.pkg.tar.zst""")config = f"""[testrepo]SigLevel = NeverServer = file://"""if 'testrepo' not in self.machine.execute('grep testrepo /etc/pacman.conf || true'):self.machine.write("/etc/pacman.conf", config, append=True)# packagekit does not detect new/updated repositories without a restart and refreshself.machine.execute("systemctl restart packagekit")self.refreshMetadata(force=True)else:self.machine.execute(f"""printf '[updates]\nname=cockpittest\nbaseurl=file://\nenabled=1\ngpgcheck=0\n' > /etc/yum.repos.d/cockpittest.repoecho '' > /tmp/updateinfo.xmlcreaterepo_cmodifyrepo_c /tmp/updateinfo.xml /repodatadnf clean all""")if refresh_repo:self.refreshMetadata()def refreshMetadata(self, *, force: bool = False) -> None:self.machine.execute(f" refresh ")def removePackages(self, packages: list[str]) -> None:packages_str = ' '.join(packages)if self.backend == "alpm":self.machine.execute(f"pacman -Rdd --noconfirm ")elif self.backend == "apt":self.machine.execute(f"dpkg --purge ")elif self.backend.startswith('dnf') or self.backend == "zypper":self.machine.execute(f"rpm --erase ")