Skip to content

Commit e06d2b8

Browse files
committed
Added dnf package module
- Uses dnf python library for interfacing with dnf - Use rpm library for currently installed packages (it's faster than dnf and /bin/rpm) Ticket: ENT-11784 Changelog: Added dnf package module
1 parent f9fa9f0 commit e06d2b8

2 files changed

Lines changed: 262 additions & 0 deletions

File tree

lib/packages.cf

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ body package_module yum
164164
@endif
165165
}
166166

167+
body package_module dnf
168+
# @brief Define details used when interfacing with dnf
169+
{
170+
query_installed_ifelapsed => "$(package_module_knowledge.query_installed_ifelapsed)";
171+
query_updates_ifelapsed => "$(package_module_knowledge.query_updates_ifelapsed)";
172+
#default_options => {};
173+
@if minimum_version(3.12.2)
174+
interpreter => "$(sys.bindir)/cfengine-selected-python";
175+
@endif
176+
}
177+
167178
body package_module slackpkg
168179
# @brief Define details used when interfacing with slackpkg
169180
{
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env python3
2+
# Note: See lib/packages.cf `package_module dnf` use of the
3+
# `interpreter` attribute to use cfengine-selected-python.
4+
5+
import sys
6+
import os
7+
import logging
8+
import dnf
9+
import rpm
10+
11+
12+
def _get_package_info_from_file(file_path):
13+
"""Extract package information from an RPM file using the python-rpm library."""
14+
ts = rpm.TransactionSet()
15+
try:
16+
with open(file_path, 'rb') as f:
17+
hdr = ts.hdrFromFdno(f.fileno())
18+
except Exception as e:
19+
raise Exception(f'Failed to read RPM header from {file_path}: {e}')
20+
21+
def _s(val):
22+
return val.decode('utf-8') if isinstance(val, bytes) else str(val)
23+
24+
name = _s(hdr[rpm.RPMTAG_NAME])
25+
version = _s(hdr[rpm.RPMTAG_VERSION])
26+
release = _s(hdr[rpm.RPMTAG_RELEASE])
27+
arch = _s(hdr[rpm.RPMTAG_ARCH])
28+
epoch = hdr[rpm.RPMTAG_EPOCH]
29+
epoch_str = '0' if epoch is None or _s(epoch) == '(none)' else _s(epoch)
30+
31+
return {
32+
'name': name,
33+
'version': version,
34+
'release': release,
35+
'arch': arch,
36+
'epoch': epoch_str,
37+
'full_version': f'{version}-{release}' if release else version,
38+
}
39+
40+
41+
def _get_base(with_repos=True):
42+
"""Create and configure a DNF base object."""
43+
base = dnf.Base()
44+
base.conf.assumeyes = True
45+
if with_repos:
46+
base.read_all_repos()
47+
base.fill_sack(load_system_repo='auto')
48+
else:
49+
base.fill_sack(load_system_repo=True, load_available_repos=False)
50+
return base
51+
52+
53+
def _parse_stdin():
54+
"""Parses stdin protocol input into (packages, options)."""
55+
packages, options, curr = [], [], {}
56+
for line in sys.stdin:
57+
k, _, v = line.strip().partition('=')
58+
if k == 'options':
59+
options.append(v)
60+
elif k in ('Name', 'File'):
61+
if curr:
62+
packages.append(curr)
63+
curr = {k.lower(): v}
64+
elif k == 'Version':
65+
curr['version'] = v
66+
elif k == 'Architecture':
67+
curr['arch'] = v
68+
if curr:
69+
packages.append(curr)
70+
return packages, options
71+
72+
73+
def _apply_options(base, options):
74+
"""Apply repository options and generic DNF configuration from the policy."""
75+
for option in options:
76+
option = option.strip()
77+
if '=' in option:
78+
key, value = [x.strip() for x in option.split('=', 1)]
79+
if key == 'enablerepo':
80+
if value in base.repos:
81+
base.repos[value].enable()
82+
elif key == 'disablerepo':
83+
if value in base.repos:
84+
base.repos[value].disable()
85+
elif hasattr(base.conf, key):
86+
if value.lower() == 'true': value = True
87+
elif value.lower() == 'false': value = False
88+
elif value.isdigit(): value = int(value)
89+
setattr(base.conf, key, value)
90+
elif option.startswith('--'):
91+
conf_key = option[2:].replace('-', '_')
92+
if hasattr(base.conf, conf_key):
93+
setattr(base.conf, conf_key, True)
94+
95+
96+
def _do_transaction(base):
97+
"""Resolves dependencies and executes the DNF transaction."""
98+
if not base.resolve():
99+
if not base.transaction: return 0
100+
if not base.transaction:
101+
return 0
102+
base.download_packages(list(base.transaction.install_set))
103+
base.do_transaction()
104+
return 0
105+
106+
107+
def _resolve_spec(pkg):
108+
"""Resolve a package specification string from a package or file path."""
109+
name = pkg.get('name')
110+
file_path = pkg.get('file')
111+
112+
path = file_path or (name if name and name.startswith('/') else None)
113+
if path:
114+
if not os.path.exists(path):
115+
raise Exception(f'Package file not found: {path}')
116+
info = _get_package_info_from_file(path)
117+
name = info['name']
118+
119+
if not name: return None
120+
spec = name
121+
if pkg.get('version'): spec += '-' + pkg.get('version')
122+
if pkg.get('arch'): spec += '.' + pkg.get('arch')
123+
return spec
124+
125+
126+
def get_package_data():
127+
packages, _ = _parse_stdin()
128+
if not packages:
129+
# Optimization: Avoid further processing if no package metadata is provided
130+
return 1
131+
pkg = packages[0]
132+
pkg_string = pkg.get('file') or pkg.get('name')
133+
if not pkg_string: return 1
134+
135+
if pkg_string.startswith('/'):
136+
info = _get_package_info_from_file(pkg_string)
137+
sys.stdout.write(f"PackageType=file\nName={info['name']}\nVersion={info['full_version']}\nArchitecture={info['arch']}\n")
138+
else:
139+
sys.stdout.write(f"PackageType=repo\nName={pkg_string}\n")
140+
sys.stdout.flush()
141+
return 0
142+
143+
def list_installed():
144+
_parse_stdin()
145+
mi = rpm.TransactionSet().dbMatch()
146+
for h in mi:
147+
def _s(val):
148+
return val.decode('utf-8') if isinstance(val, bytes) else str(val)
149+
name = _s(h['name'])
150+
ver = _s(h['version'])
151+
rel = _s(h['release'])
152+
arch = _s(h['arch'])
153+
sys.stdout.write(f'Name={name}\nVersion={ver}-{rel}\nArchitecture={arch}\n')
154+
return 0
155+
156+
def list_updates(online):
157+
packages, options = _parse_stdin()
158+
base = _get_base(with_repos=True)
159+
base.conf.cacheonly = not online
160+
_apply_options(base, options)
161+
try:
162+
base.upgrade_all()
163+
if base.resolve():
164+
for tsi in base.transaction:
165+
if tsi.action == dnf.transaction.PKG_UPGRADE:
166+
v_str = f'{tsi.pkg.version}-{tsi.pkg.release}'
167+
sys.stdout.write(f'Name={tsi.pkg.name}\nVersion={v_str}\nArchitecture={tsi.pkg.arch}\n')
168+
finally: base.close()
169+
return 0
170+
171+
def repo_install():
172+
packages, options = _parse_stdin()
173+
if not packages:
174+
# Optimization: Avoid expensive DNF base initialization if no packages are provided
175+
return 0
176+
base = _get_base(with_repos=True)
177+
_apply_options(base, options)
178+
for pkg in packages:
179+
spec = _resolve_spec(pkg)
180+
if spec:
181+
base.install(spec)
182+
return _do_transaction(base)
183+
184+
def remove():
185+
packages, options = _parse_stdin()
186+
if not packages:
187+
# Optimization: Avoid DNF base initialization if no packages are provided
188+
return 0
189+
base = _get_base(with_repos=False)
190+
_apply_options(base, options)
191+
for pkg in packages:
192+
spec = _resolve_spec(pkg)
193+
if spec:
194+
base.remove(spec)
195+
return _do_transaction(base)
196+
197+
def file_install():
198+
packages, options = _parse_stdin()
199+
if not packages:
200+
# Optimization: Avoid DNF base initialization if no packages are provided
201+
return 0
202+
base = _get_base(with_repos=True)
203+
_apply_options(base, options)
204+
rpm_files = [p['file'] for p in packages if p.get('file')]
205+
if not rpm_files:
206+
# Optimization: Avoid further processing if no file paths were successfully parsed
207+
return 0
208+
for f in rpm_files:
209+
if not os.path.exists(f):
210+
raise Exception(f'Package file not found: {f}')
211+
for pkg in base.add_remote_rpms(rpm_files):
212+
base.package_install(pkg)
213+
return _do_transaction(base)
214+
215+
def supports_api_version():
216+
"""Report the supported package module API version."""
217+
sys.stdout.write('1\n')
218+
return 0
219+
220+
def main():
221+
if len(sys.argv) < 2:
222+
return 2
223+
op = sys.argv[1]
224+
225+
# Dispatch table for protocol commands
226+
commands = {
227+
'supports-api-version': supports_api_version,
228+
'get-package-data': get_package_data,
229+
'list-installed': list_installed,
230+
'list-updates': lambda: list_updates(online=True),
231+
'list-updates-local': lambda: list_updates(online=False),
232+
'repo-install': repo_install,
233+
'remove': remove,
234+
'file-install': file_install,
235+
}
236+
237+
if op not in commands:
238+
return 2
239+
240+
try:
241+
return commands[op]()
242+
except Exception as e:
243+
# Proper error output for CFEngine protocol
244+
sys.stdout.write(f'ErrorMessage={str(e)}\n')
245+
sys.stdout.flush()
246+
return 1
247+
248+
249+
if __name__ == '__main__':
250+
logging.basicConfig(level=logging.WARNING, format='%(message)s', handlers=[logging.StreamHandler(sys.stderr)])
251+
sys.exit(main())

0 commit comments

Comments
 (0)