Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add JSON output for updateinfo #2200

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 95 additions & 11 deletions dnf/cli/commands/updateinfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
from dnf.i18n import _, exact_width
from dnf.pycomp import unicode

import sys
import json

def _maxlen(iterable):
"""Return maximum length of items in a non-empty iterable."""
Expand Down Expand Up @@ -102,6 +104,9 @@ def set_argparser(parser):
parser.add_argument("--with-bz", dest='with_bz', default=False,
action='store_true',
help=_('show only advisories with bugzilla reference'))
parser.add_argument("--json", dest='json', default=False,
action='store_true',
help=_('Display in JSON format.'))
parser.add_argument('spec', nargs='*', metavar='SPEC',
choices=cmds, default=cmds[0],
action=OptionParser.PkgNarrowCallback,
Expand Down Expand Up @@ -157,6 +162,10 @@ def configure(self):
else:
self.opts.spec.insert(0, spec)

# Keep quiet when dumping JSON output
if self.opts.json:
self.cli.redirect_logger(stdout=sys.maxsize, stderr=sys.maxsize)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good but there still are some redundant messages in the output, namely I think the download progress bars.

We could additionally do something similar to what the --quiet option does: https://github.com/rpm-software-management/dnf/blob/master/dnf/cli/cli.py#L834-L836

To take effect I believe it would have to be set in pre_configure of the command.
I would add something like:

def pre_configure(self):
       if self.opts.json:
            self.base.conf.debuglevel = 0

Perhaps don't change the errorlevel since those message are more crucial.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack. will test this;)


if self.opts.advisory:
self.opts.spec.extend(self.opts.advisory)

Expand Down Expand Up @@ -327,10 +336,10 @@ def type2label(typ, sev):
elif ref.type == hawkey.REFERENCE_CVE and not self.opts.with_cve:
continue
nevra_inst_dict.setdefault((nevra, installed, advisory.updated), dict())[ref.id] = (
advisory.type, advisory.severity)
advisory.type, advisory.severity)
else:
nevra_inst_dict.setdefault((nevra, installed, advisory.updated), dict())[advisory.id] = (
advisory.type, advisory.severity)
advisory.type, advisory.severity)

advlist = []
# convert types to labels, find max len of advisory IDs and types
Expand All @@ -339,15 +348,84 @@ def type2label(typ, sev):
nw = max(nw, len(nevra))
for aid, atypesev in id2type.items():
idw = max(idw, len(aid))
typ, sev = atypesev
label = type2label(*atypesev)
# use dnf5 style for JSON output
atype = self.TYPE2LABEL.get(typ, _('unspecified'))
asev = self.SECURITY2LABEL.get(sev, _('None'))
asev = asev.split("/")[0].strip()
Comment on lines +355 to +356
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a simpler solution would be to just do:

asev = sev

It is also what dnf5 does so the output will be more compatible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for severity, for dnf updateinfo --list output, the format is Medium/Sec for cve type, and single word for others,

i FEDORA-2024-3c18fe0d93 Important/Sec. python-unversioned-command-3.13.1-2.fc41.noarch
i FEDORA-2025-e911f71d99 Moderate/Sec.  python-unversioned-command-3.13.2-1.fc41.noarch
i FEDORA-2024-3c18fe0d93 Important/Sec. python3-3.13.1-2.fc41.x86_64
i FEDORA-2025-e911f71d99 Moderate/Sec.  python3-3.13.2-1.fc41.x86_64
i FEDORA-2025-52b16605d4 bugfix         python3-argcomplete-3.5.3-1.fc41.noarch
i FEDORA-2025-397632c71b bugfix         python3-babel-2.17.0-1.fc41.noarch

while for dnf5 json output, it's simply Medium. example as below. reference rpm-software-management/dnf5#1531. that was the reason asev didn't copy from sev.

# dnf advisory list --json | head -20
[
  {
    "name":"FEDORA-2024-56efaa7783",
    "type":"enhancement",
    "severity":"Low",
    "nevra":"alternatives-1.31-1.fc41.x86_64",
    "buildtime":"2024-12-22 02:00:45"
  },
  {
    "name":"FEDORA-2025-9bef5569b2",
    "type":"enhancement",
    "severity":"None",
    "nevra":"audit-libs-4.0.3-1.fc41.x86_64",
    "buildtime":"2025-01-10 01:32:24"
  },
  {
    "name":"FEDORA-2024-4b75866373",
    "type":"bugfix",
    "severity":"Low",
    "nevra":"coreutils-9.5-11.fc41.x86_64",

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For dnf4 the list output contains a type (bugfix, enhancement, security..) and when the type is security it also contains the severity (Low, Moderate, Important...) so it combines them both.

With dnf5 json both type and severity are always present but they are separated. I think this is better for the json output.

tlw = max(tlw, len(label))
advlist.append((inst2mark(inst), aid, label, nevra, aupdated))

for (inst, aid, label, nevra, aupdated) in advlist:
if self.base.conf.verbose:
print('%s%-*s %-*s %-*s %s' % (inst, idw, aid, tlw, label, nw, nevra, aupdated))
else:
print('%s%-*s %-*s %s' % (inst, idw, aid, tlw, label, nevra))
advlist.append((inst2mark(inst), aid, label, atype, asev, nevra, aupdated))
if self.opts.json:
dtlst = []
for (inst, aid, label, atype, asev, nevra, aupdated) in advlist:
dtlst.append(
{
"name": aid,
"type": atype,
"severity": asev,
"nevra": nevra,
"buildtime": aupdated,
Comment on lines +362 to +368
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dnf5 has special logic when --with-bz or --with-cve is used: https://github.com/rpm-software-management/dnf5/blob/main/dnf5/commands/advisory/advisory_list.cpp#L75-L81
It adds references to the list subcommand and changes the IDs I think it would be good to be compatible as much as possible. Could you look into that?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, ack.

}
)
print(json.dumps(dtlst, default=str, indent=2))
else:
for (inst, aid, label, atype, asev, nevra, aupdated) in advlist:
if self.base.conf.verbose:
print('%s%-*s %-*s %-*s %s' % (inst, idw, aid, tlw, label, nw, nevra, aupdated))
else:
print('%s%-*s %-*s %s' % (inst, idw, aid, tlw, label, nevra))

def _process_advisory(self, advisory):
"""Convert DNF advisory object directly to desired format."""
advisory_id = getattr(advisory, 'id', None)

package_list = []
for pkg in getattr(advisory, 'packages', []):
if not getattr(pkg, 'name', None):
continue
pkg_info = {
'name': getattr(pkg, 'name', None),
'evr': getattr(pkg, 'evr', None),
'arch': getattr(pkg, 'arch', None),
}
pkg_str = f"{pkg_info.get('name')}-{pkg_info.get('evr')}"
if pkg_info.get('arch'):
pkg_str += f".{pkg_info.get('arch')}"
package_list.append(pkg_str)

REFERENCE_TYPES = {0: 'unknown', 1: 'bugzilla', 2: 'cve', 3: 'vendor', 4: 'security'}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there is no reference type security.

Also you should not define the numbers here by hand, it would be better to use the exported hawkey constatns:

REFERENCE_TYPES = {hawkey.REFERENCE_UNKNOWN: 'unknown', hawkey.REFERENCE_BUGZILLA: 'bugzilla',
                   hawkey.REFERENCE_CVE: 'cve', hawkey.REFERENCE_VENDOR: 'vendor'}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack

references = []
for ref in getattr(advisory, 'references', []):
ref_dict = {
'Title': getattr(ref, 'title', None),
'Id': getattr(ref, 'id', None),
'Type': REFERENCE_TYPES.get(getattr(ref, 'type', 0), 'unknown'),
'Url': getattr(ref, 'href', None) or getattr(ref, 'url', None) or getattr(ref, 'link', None)
}
ref_dict = {k: v for k, v in ref_dict.items() if v is not None}
references.append(ref_dict)

result = {
advisory_id: {
'Name': advisory_id,
'Title': getattr(advisory, 'title', None),
'Severity': getattr(advisory, 'severity', 'None'),
'Type': self.TYPE2LABEL.get(getattr(advisory, 'type'), _('unspecified')),
'Issued': getattr(advisory, 'updated', '').strftime("%Y-%m-%d %H:%M:%S")
if getattr(advisory, 'updated', None)
else None,
'Description': getattr(advisory, 'description', None),
'Message': '',
'Rights': getattr(advisory, 'rights', None),
'references': references,
'collections': {
'packages': package_list
}
}
}

return result


def display_info(self, apkg_adv_insts):
Expand Down Expand Up @@ -398,8 +476,14 @@ def advisory2info(advisory, installed):
lines.append('%*s%s: %s' % (key_padding, "", key, line))
return '\n'.join(lines)

dt_advisories = {}
advisories = set()
for apkg, advisory, installed in apkg_adv_insts:
advisories.add(advisory2info(advisory, installed))
dt_advisories.update(self._process_advisory(advisory))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer if we did this only when the self.opts.json is set otherwise its running always even when not used.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ack.

formatted_attributes = advisory2info(advisory, installed)
advisories.add(formatted_attributes)

print("\n\n".join(sorted(advisories, key=lambda x: x.lower())))
if self.opts.json:
print(json.dumps(dt_advisories, default=str, indent=2))
else:
print("\n\n".join(sorted(advisories, key=lambda x: x.lower())))
Loading