/* * Copyright (C) 2025 Red Hat, Inc. * SPDX-License-Identifier: LGPL-2.1-or-later */ import cockpit from "cockpit"; import { superuser } from 'superuser'; import { InstallProgressCB, MissingPackages, PackageManager, ProgressCB, ResolveError, InstallProgressType, UpdateDetail, Update, Severity, History, UpdateProgressHandlers, TransactionExitStatus, RunningUpdate } from './packagemanager-abstract'; let _dbus_client: cockpit.DBusClient | null = null; /** * Get dnf5daemon D-Bus client * * This will get lazily initialized and re-initialized after dnf5daemon * disconnects (due to a crash or idle timeout). */ function dbus_client() { if (_dbus_client === null) { _dbus_client = cockpit.dbus("org.rpm.dnf.v0", { superuser: "try", track: true }); _dbus_client.addEventListener("close", () => { console.warn("dnf5daemon went away from D-Bus"); _dbus_client = null; }); } return _dbus_client; } // Reconnect when privileges change superuser.addEventListener("changed", () => { if (_dbus_client) _dbus_client.close(); _dbus_client = null; }); async function open_session(): Promise { const [session] = await call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager", "open_session", [{}]) as string[]; return session; } function close_session(session: string) { return call("/org/rpm/dnf/v0", "org.rpm.dnf.v0.SessionManager", "close_session", [session]); } interface ListPackage { arch: { t: "s", v: string } download_size: { t: "t", "v": number } id: { t: "i"; v: number }; is_installed: { t: "b"; v: boolean }; name: { t: "s"; v: string }; release: { t: "s"; v: string }; version: { t: "s"; v: string }; nevra?: { t: "s"; v: string }; } interface CollectionPackage { // arch a: { t: "s"; v: string }; // epoch e: { t: "s"; v: string }; // name n: { t: "s"; v: string }; nevra: { t: "s"; v: string }; // release r: { t: "s"; v: string }; // version v: { t: "s"; v: string }; } type AdvisoryType = "bugfix" | "enhancement" | "security" | "newpackage"; interface ListAdvisory { advisoryid: { t: "i", v: number }; name: { t: "s", v: string }; description: { t: "s", v: string }; status: { t: "s", v: string }; severity: { t: "s", v: Severity }; type: { t: "s", v: AdvisoryType }; collections: { t: "aa{sv}", v: [ { packages: { t: "aa{sv}", v: CollectionPackage[] } } ] } // Array of id, type, title, url references: { t: "a(ssss)", v: [string, string, string, string] } } interface ResolvePackage { arch: { t: "s"; v: string }; download_size: { t: "t"; v: number }; epoch: { t: "s"; v: string }; evr: { t: "s"; v: string }; from_repo_id: { t: "s"; v: string }; id: { t: "i"; v: number }; install_size: { t: "t"; v: number }; name: { t: "s"; v: string }; reason: { t: "s"; v: string }; release: { t: "s"; v: string }; repo_id: { t: "s"; v: string }; version: { t: "s"; v: string }; } interface TransactionProblem { action: { t: "u", v: number }; additional_data: { t: "as", "v": string[] }; goal_job_settings: { t: "a{vs}", "v": { to_repo_ids: { t: "as", v: string[] } } }; problem: { t: "u", v: number }; spec: { t: "s", v: "appstream-data" }; } interface RepoListResult { cache_updated: { t: "x", "v": number }; } interface HistoryListPackage { arch: { t: "s", v: string } evr: { t: "s", v: string } name: { t: "s", v: string } } interface HistoryListResult { id: { t: "x", "v": number }; start: { t: "x", "v": number }; end: { t: "x", "v": number }; user_id: { t: "u", "v": number }; status: { t: "s", "v": string }; description: { t: "s", "v": string }; downgraded: { t: "aa{sv}", "v": HistoryListPackage[] }; installed: { t: "aa{sv}", "v": HistoryListPackage[] }; reinstalled: { t: "aa{sv}", "v": HistoryListPackage[] }; removed: { t: "aa{sv}", "v": HistoryListPackage[] }; upgraded: { t: "aa{sv}", "v": HistoryListPackage[] }; } enum GoalProblem { ALREADY_INSTALLED = (1 << 12) } // TransactionItemType type object_type = "Package" | "Group" | "Environment" | "Module" | "Skipped"; // TransactionItemAction type action = "Install" | "Upgrade" | "Downgrade" | "Reinstall" | "Remove" | "Replaced" | "Reset" | "Enable" | "Disable" | "Reason Change" | "Switch" // TransactionItemReason type reason = "User" | "Dependency" | "Clean" | "Group" | "None" | "Weak Dependency" | "External User" type TransactionItem = [ object_type, action, reason, unknown, ResolvePackage ] type InstallResolveResult = [TransactionItem[], number] type RemoveResolveResult = [TransactionItem[], number] type UpgradeResolveResult = [TransactionItem[], number] type SignalCB = (_path: string, _iface: string, signal: string, args: unknown[]) => void /** * Call a dnf5daemon method */ function call(objectPath: string, iface: string, method: string, args?: unknown[], opts?: cockpit.DBusCallOptions) { return dbus_client().call(objectPath, iface, method, args, opts); } export class Dnf5DaemonManager implements PackageManager { name: string; constructor() { this.name = 'dnf5daemon'; } private async with_session(executor: (session: string) => Promise, signal_handler?: SignalCB): Promise { let session: string | null = null; let subscription: { remove: () => void } | null = null; const client = dbus_client(); if (signal_handler) subscription = client.subscribe({}, signal_handler); try { session = await open_session(); await executor(session); } finally { if (session) await close_session(session); if (subscription) subscription.remove(); } } async check_missing_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise { const data: MissingPackages = { extra_names: [], missing_ids: [], missing_names: [], unavailable_names: [], remove_names: [], download_size: 0, }; if (pkgnames.length === 0) return data; async function resolve(session: string) { const installed_names = new Set(); const seen_names = new Set(); const package_attrs = ["name", "is_installed"]; const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [ { package_attrs: { t: 'as', v: package_attrs }, scope: { t: 's', v: "all" }, patterns: { t: 'as', v: pkgnames } } ]) as ListPackage[][]; for (const pkg of results) { if (seen_names.has(pkg.name.v)) continue; if (pkg.is_installed.v) { installed_names.add(pkg.name.v); } else { data.missing_ids.push(pkg.name.v); data.missing_names.push(pkg.name.v); } seen_names.add(pkg.name.v); } pkgnames.forEach((name: string) => { if (!installed_names.has(name) && data.missing_names.indexOf(name) === -1) data.unavailable_names.push(name); }); } async function simulate(session: string) { if (data.missing_ids.length === 0 || data.unavailable_names.length > 0) { return null; } await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [pkgnames, {}]); const [transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as InstallResolveResult; if (result !== 0) { const [problems] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems", []) as TransactionProblem[][]; // If all the problems relating to the check for missing packages is about it already being installed, clear it if (problems.every((p: TransactionProblem) => p.problem.v === GoalProblem.ALREADY_INSTALLED)) { await call(session, "org.rpm.dnf.v0.Goal", "reset", []); } else { const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving install failed with result=${result}. ${problem}`); } } for (const transaction_item of transaction_items) { const [object_type, action, reason, _transaction_item_attributes, pkg] = transaction_item; const name = pkg.name.v; if (object_type !== "Package") { console.error(`Simulated install for ${pkgnames} resolved an unexpected object_type=${object_type}`); continue; } data.download_size += pkg.download_size.v; if (reason === "Dependency") { if (data.missing_names.indexOf(name) === -1) data.extra_names.push(name); } if (action === "Replaced") { if (data.remove_names.indexOf(name) === -1) data.remove_names.push(name); } } // Call reset() as we don't intend to complete the transaction using `do_transaction` await call(session, "org.rpm.dnf.v0.Goal", "reset", []); } function signal_emitted(_path: string, _iface: string, _signal: string, _args: unknown[]) { // HACK: dnf5daemon doesn't give us an useful progress indicator so the progress percentage is hardcoded to 0. if (progress_cb) { progress_cb({ waiting: false, percentage: 0, cancel: null, }); } } await this.refresh(false); await this.with_session(async (session) => { try { await resolve(session); await simulate(session); } catch (err) { console.warn("check_missing_packages", err); throw err; } }, signal_emitted); return data; } async install_missing_packages(data: MissingPackages, progress_cb?: InstallProgressCB): Promise { if (!data || data.missing_ids.length === 0) return; let last_info: number; let last_progress = 0; let last_name: string; let total_packages: number; function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) { switch (signal) { // download_add_new(o session_object_path, s download_id, s description, x total_to_download) case 'download_add_new': { last_info = InstallProgressType.DOWNLOADING; last_name = args[2] as string; break; } // download_progress(o session_object_path, s download_id, x total_to_download, x downloaded) case 'download_progress': { last_info = InstallProgressType.DOWNLOADING; break; } // download_end(o session_object_path, s download_id, u transfer_status, s message) case 'download_end': last_info = 0; last_name = ""; break; // transaction_before_begin(o session_object_path, t total) case 'transaction_before_begin': [, total_packages] = args as [string, number]; last_info = InstallProgressType.INSTALLING; break; // transaction_elem_progress(o session_object_path, s nevra, t processed, t total) case 'transaction_elem_progress': { let processed = 0; [, last_name, processed,] = args as [string, string, number, number]; last_progress = processed / total_packages * 100; break; } } if (progress_cb) progress_cb({ cancel: null, info: last_info, package: last_name, percentage: last_progress, waiting: false, }); } await this.with_session(async (session) => { try { await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [data.missing_names, {}]); const [, resolve_result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]); if (resolve_result !== 0) { const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving install failed with result=${resolve_result} ${problem}`); } await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]); } catch (err) { console.warn("install error", err); } }, signal_emitted); } async refresh(_force: boolean, _progress_cb?: ProgressCB): Promise { await this.with_session(async (session) => { // refresh dnf5daemon state await call(session, "org.rpm.dnf.v0.Base", "read_all_repos", []); const [, resolve_result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as [unknown[], number]; if (resolve_result !== 0) { const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving read_all_repos failed with result=${resolve_result} - ${problem}`); } await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]); }); } async is_installed(pkgnames: string[]): Promise { const uninstalled = new Set(pkgnames); await this.with_session(async (session) => { const package_attrs = ["name", "is_installed"]; const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [ { package_attrs: { t: 'as', v: package_attrs }, scope: { t: 's', v: "all" }, patterns: { t: 'as', v: pkgnames } } ]) as ListPackage[][]; for (const pkg of results) { if (pkg.is_installed.v) { uninstalled.delete(pkg.name.v); } } }); return uninstalled.size === 0; } async install_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise { let last_progress = 0; let total_packages: number; function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) { switch (signal) { case 'transaction_before_begin': [, total_packages] = args as [string, number]; break; case 'transaction_elem_progress': { const [, _last_name, processed,] = args as [string, string, number, number]; last_progress = processed / total_packages * 100; break; } } if (progress_cb) { progress_cb({ waiting: false, percentage: last_progress, cancel: null, }); } } await this.with_session(async (session) => { await call(session, "org.rpm.dnf.v0.rpm.Rpm", "install", [pkgnames, {}]); const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as InstallResolveResult; if (result !== 0) { const [problems] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems", []) as TransactionProblem[][]; if (problems.every((p: TransactionProblem) => p.problem.v === GoalProblem.ALREADY_INSTALLED)) { await call(session, "org.rpm.dnf.v0.Goal", "reset", []); return; } const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving install failed with result=${result}. ${problem}`); } await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]); }, signal_emitted); } async remove_packages(pkgnames: string[], progress_cb?: ProgressCB): Promise { let last_progress = 0; let total_packages: number; function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) { switch (signal) { case 'transaction_before_begin': [, total_packages] = args as [string, number]; break; case 'transaction_elem_progress': { const [, _last_name, processed,] = args as [string, string, number, number]; last_progress = processed / total_packages * 100; break; } } if (progress_cb) { progress_cb({ waiting: false, percentage: last_progress, cancel: null, }); } } await this.with_session(async (session) => { await call(session, "org.rpm.dnf.v0.rpm.Rpm", "remove", [pkgnames, {}]); const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as RemoveResolveResult; if (result !== 0) { const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving remove failed with result=${result}. ${problem}`); } await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]); }, signal_emitted); } async find_file_packages(files: string[], progress_cb?: ProgressCB): Promise { const installed: string[] = []; await this.with_session(async (session) => { const package_attrs = ["name"]; const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [ { package_attrs: { t: 'as', v: package_attrs }, scope: { t: 's', v: "installed" }, patterns: { t: 'as', v: files }, with_filenames: { t: 'b', v: true }, } ]) as ListPackage[][]; for (const result of results) { installed.push(result.name.v); } // HACK: no usable progress event, but we need to send something to make refresh work. if (progress_cb) progress_cb({ percentage: 100, waiting: false, cancel: null }); }); return installed; } async get_updates(detail: T, _progress_cb?: ProgressCB): Promise { const update_map = new Map(); const package_attrs = ["name", "version", "arch", "epoch", "nevra"]; await this.with_session(async (session) => { const pkgnames = []; const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [ { package_attrs: { t: 'as', v: package_attrs }, scope: { t: 's', v: "upgrades" }, } ]) as ListPackage[][]; for (const result of results) { cockpit.assert(result.nevra, "nevra not set"); pkgnames.push(result.name.v); update_map.set(result.nevra.v, { id: result.name.v, name: result.name.v, arch: result.arch.v, version: result.version.v, }); } if (detail) { const advisory_attrs = ["advisoryid", "name", "title", "type", "severity", "description", "references", "collections", "message"]; const [advisories] = await call(session, "org.rpm.dnf.v0.Advisory", "list", [ { advisory_attrs: { t: 'as', v: advisory_attrs }, availability: { t: 's', v: "upgrades" }, contains_pkgs: { t: 'as', v: pkgnames }, } ]) as ListAdvisory[][]; for (const advisory of advisories) { for (const collection of advisory.collections.v) { for (const pkg of collection.packages.v) { let update = update_map.get(pkg.nevra.v); if (!update) continue; const bug_urls: string[] = []; const cve_urls: string[] = []; const vendor_urls: string[] = []; for (const [, type, _title, url] of advisory.references.v) { switch (type) { case "bugzilla": bug_urls.push(url); break; case "cve": cve_urls.push(url); break; case "vendor": vendor_urls.push(url); break; } } // Map the advisory type to the severity which PackageKit uses // Critical == Security update // Important == Bug fix // Moderate == Enhancement let severity = Severity.LOW; switch (advisory.type.v) { case "bugfix": severity = Severity.IMPORTANT; break; case "enhancement": severity = Severity.MODERATE; break; case "security": severity = Severity.CRITICAL; break; } update = { ...update, description: advisory.description.v, severity, markdown: false, bug_urls, cve_urls, vendor_urls, }; update_map.set(pkg.nevra.v, update); break; } } } } }); return Array.from(update_map.values()) as T extends true ? UpdateDetail[] : Update[]; } async update_packages(updates: Update[] | UpdateDetail[], handlers: UpdateProgressHandlers): Promise { const pkgnames = updates.map(update => update.id); let last_progress = 0; let total_packages: number; function signal_emitted(_path: string, _iface: string, signal: string, args: unknown[]) { switch (signal) { case 'transaction_before_begin': [, total_packages] = args as [string, number]; break; case 'transaction_elem_progress': { const [, _last_name, processed,] = args as [string, string, number, number]; last_progress = processed / total_packages * 100; break; } } handlers.on_notify({ percentage: last_progress, cancel: null, }); } await this.with_session(async (session) => { await call(session, "org.rpm.dnf.v0.rpm.Rpm", "upgrade", [pkgnames, {}]); const [_transaction_items, result] = await call(session, "org.rpm.dnf.v0.Goal", "resolve", [{}]) as UpgradeResolveResult; if (result !== 0) { const [problem] = await call(session, "org.rpm.dnf.v0.Goal", "get_transaction_problems_string", []); throw new ResolveError(`Resolving upgrade failed with result=${result}. ${problem}`); } await call(session, "org.rpm.dnf.v0.Goal", "do_transaction", [{}]); }, signal_emitted); return TransactionExitStatus.SUCCESS; } async get_running_update(_handlers: UpdateProgressHandlers): Promise { return null; } async get_backend(): Promise { return "dnf5"; } async get_last_refresh_time(): Promise { let last_time = 0; await this.with_session(async (session) => { // Bug? Does this need load repo? As the result was somehow -1 at one point. const [results] = await call(session, "org.rpm.dnf.v0.rpm.Repo", "list", [{ repo_attrs: { t: 'as', v: ['cache_updated'] } }]) as RepoListResult[][]; for (const result of results) { if (result.cache_updated.v > last_time) last_time = result.cache_updated.v; } }); const now = parseInt(await cockpit.spawn(["date", "+%s"]), 10); return now - last_time; } async get_history(): Promise { const history: History[] = []; await this.with_session(async (session) => { // TODO: arbitrary 50 limit, the history API has no limit const [results] = await call(session, "org.rpm.dnf.v0.History", "list", [{ limit: { t: 'x', v: 50 } }]) as HistoryListResult[][]; for (const result of results) { // The PackageKit page only shows dnf upgrades, filter out the rest if (result.upgraded.v.length === 0) continue; const timestamp = result.end.v; const pkgs = { timestamp, packages: {} } as History; for (const pkg of result.upgraded.v) { pkgs.packages[pkg.name.v] = pkg.evr.v; } history.push(pkgs); } }); return history; } async is_available(pkgnames: string[]): Promise { const available = new Set(); await this.with_session(async (session) => { const [results] = await call(session, "org.rpm.dnf.v0.rpm.Rpm", "list", [ { package_attrs: { t: 'as', v: ["name"] }, scope: { t: 's', v: "available" }, patterns: { t: 'as', v: pkgnames } } ]) as ListPackage[][]; for (const pkg of results) { available.add(pkg.name.v); } }); return available.size === new Set(pkgnames).size; } }