Files
fm-orchestrator/module_build_service/resolver/MBSResolver.py
Owen W. Taylor f5100609aa Filter console output through a special handler
When running in local mode, add a special handler that filters log
messages to the console to produce attractive output. Implemented
behaviors include:

 - INFO level messages are only displayed if done through
   MBSLogger.console() rather than MBSLogger.info().
 - Timestamps and thread names are omitted unless the local build
   is started with the -d option
 - Warning/error messages have the level highlighted in red
 - Special handling can be added to log messages, initially:
    - Long running operations can be displayed to the console as
      "Doing foo ... <pause>done"
2023-03-15 17:16:24 +00:00

575 lines
22 KiB
Python

# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
"""MBS handler functions."""
from __future__ import absolute_import
import dogpile.cache
from module_build_service.common import log, models
from module_build_service.common.config import conf
from module_build_service.common.errors import StreamNotXyz, UnprocessableEntity
from module_build_service.common.request_utils import requests_session
from module_build_service.common.utils import load_mmd, import_mmd
from module_build_service.resolver.KojiResolver import KojiResolver
def _canonicalize_state(state):
if isinstance(state, int):
return models.INVERSE_BUILD_STATES[state]
else:
return state
def _canonicalize_states(states):
if states:
result = sorted({_canonicalize_state(s) for s in states})
if result == ["ready"]:
return None
else:
return result
class MBSResolver(KojiResolver):
backend = "mbs"
region = dogpile.cache.make_region().configure("dogpile.cache.memory")
def __init__(self, db_session, config):
self.db_session = db_session
self.mbs_prod_url = config.mbs_url
def _query_from_nsvc(self, name, stream, version=None, context=None, states=None):
"""
Generates dict with MBS params query.
:param str name: Name of the module to query.
:param str stream: Stream of the module to query.
:param str version/int: Version of the module to query.
:param str context: Context of the module to query.
"""
states = states or ["ready"]
query = {
"name": name,
"state": states,
"verbose": True,
"order_desc_by": "version",
}
if stream is not None:
query["stream"] = stream
if version is not None:
query["version"] = str(version)
if context is not None:
query["context"] = context
return query
@region.cache_on_arguments()
def _get_module_by_nsvc(
self, name, stream, version, context
):
"""
Query a single module from MBS
:param str name: Name of the module to query.
:param str stream: Stream of the module to query.
:param str version/int: Version of the module to query.
:param str context: Context of the module to query.
"""
query = self._query_from_nsvc(name, stream, version, context)
query['per_page'] = 5
query['page'] = 1
res = requests_session.get(self.mbs_prod_url, params=query)
res.raise_for_status()
data = res.json()
modules = data["items"]
if modules:
return modules[0]
else:
return None
# Args must exactly match call in _get_modules; newer versions of dogpile.cache
# have dogpile.cache.util.kwarg_function_key_generator which could help
@region.cache_on_arguments()
def _get_modules_with_cache(
self, name, stream, version, context,
states, base_module_br, virtual_stream, stream_version_lte
):
query = self._query_from_nsvc(name, stream, version, context, states)
query["page"] = 1
query["per_page"] = 5
if virtual_stream is not None:
query["virtual_stream"] = virtual_stream
if stream_version_lte is not None:
query["stream_version_lte"] = stream_version_lte
if base_module_br is not None:
query["base_module_br"] = base_module_br
modules = []
while True:
res = requests_session.get(self.mbs_prod_url, params=query)
res.raise_for_status()
data = res.json()
modules_per_page = data["items"]
modules += modules_per_page
if not data["meta"]["next"]:
break
if version is None and stream_version_lte is None:
# Stop querying when we've gotten a different version
if modules_per_page[-1]["version"] != modules[0]["version"]:
break
query["page"] += 1
if version is None and stream_version_lte is None:
# Only return the latest version
results = [m for m in modules if m["version"] == modules[0]["version"]]
else:
results = modules
for m in results:
# We often come back and query details again for the module builds we return
# using the retrieved contexts, so prime the cache for a single-module queries
self._get_module_by_nsvc.set(
m, self, m["name"], m["stream"], m["version"], m["context"]
)
return results
def _get_modules(
self, name, stream, version=None, context=None, states=None,
base_module_br=None, virtual_stream=None, stream_version_lte=None,
strict=False,
):
"""Query and return modules from MBS with specific info
:param str name: module's name.
:param str stream: module's stream.
:kwarg str version: a string or int of the module's version. When None,
latest version will be returned.
:kwarg str context: module's context. Optional.
:kwarg str states: states for modules. Defaults to ``["ready"]``.
:kwarg str base_module_br: if set, restrict results to modules requiring
this base_module nsvc
:kwarg list virtual_stream: if set, limit to modules for the given virtual_stream
:kwarg float stream_version_lte: If set, stream must be less than this version
:kwarg bool strict: Normally this function returns None if no module can be
found. If strict=True, then an UnprocessableEntity is raised.
:return: final list of module_info which pass repoclosure
:rtype: list[dict]
"""
modules = self._get_modules_with_cache(
name, stream, version, context,
states, base_module_br, virtual_stream, stream_version_lte
)
if not modules and strict:
raise UnprocessableEntity("Failed to find module in MBS %s:%s:%s:%s" %
(name, stream, version, context))
else:
return modules
def get_module(self, name, stream, version, context, strict=False):
if version and context:
rv = self._get_module_by_nsvc(name, stream, version, context)
if strict and rv is None:
raise UnprocessableEntity("Failed to find module in MBS")
return rv
rv = self._get_modules(name, stream, version, context, strict=strict)
if rv:
return rv[0]
def get_module_count(self, **kwargs):
"""
Determine the number of modules that match the provided filter.
:return: the number of modules that match the provided filter
:rtype: int
"""
query = {"page": 1, "per_page": 1, "short": True}
query.update(kwargs)
res = requests_session.get(self.mbs_prod_url, params=query)
res.raise_for_status()
data = res.json()
return data["meta"]["total"]
def get_latest_with_virtual_stream(self, name, virtual_stream):
"""
Get the latest module with the input virtual stream based on the stream version and version.
:param str name: the module name to search for
:param str virtual_stream: the module virtual stream to search for
:return: the module's modulemd or None
:rtype: Modulemd.ModuleStream or None
"""
query = {
"name": name,
"order_desc_by": ["stream_version", "version"],
"page": 1,
"per_page": 1,
"verbose": True,
"virtual_stream": virtual_stream,
}
res = requests_session.get(self.mbs_prod_url, params=query)
res.raise_for_status()
data = res.json()
if data["items"]:
return load_mmd(data["items"][0]["modulemd"])
def _modules_to_modulemds(self, modules, strict):
if not modules:
return []
mmds = []
for module in modules:
yaml = module["modulemd"]
if not yaml:
if strict:
raise UnprocessableEntity(
"Failed to find modulemd entry in MBS for %r" % module)
else:
log.warning("Failed to find modulemd entry in MBS for %r", module)
continue
mmds.append(load_mmd(yaml))
return mmds
def get_module_modulemds(
self,
name,
stream,
version=None,
context=None,
strict=False
):
"""
Gets the module modulemds from the resolver.
:param name: a string of the module's name
:param stream: a string of the module's stream
:param version: a string or int of the module's version. When None, latest version will
be returned.
:param context: a string of the module's context. When None, all contexts will
be returned.
:kwarg strict: Normally this function returns [] if no module can be
found. If strict=True, then a UnprocessableEntity is raised.
:return: List of Modulemd metadata instances matching the query
"""
local_modules = models.ModuleBuild.local_modules(self.db_session, name, stream)
if local_modules:
return [m.mmd() for m in local_modules]
modules = self._get_modules(name, stream, version, context, strict=strict)
return self._modules_to_modulemds(modules, strict)
def get_compatible_base_module_modulemds(
self, base_module_mmd, stream_version_lte, virtual_streams, states
):
"""
Returns the Modulemd metadata of base modules compatible with base module
defined by `name` and `stream`. The compatibility is found out using the
stream version in case the stream is in "x.y.z" format and is limited to
single major version of stream version.
If `virtual_streams` are defined, the compatibility is also extended to
all base module streams which share the same virtual stream.
:param base_module_mmd: Modulemd medatada defining the input base module.
:param stream_version_lte: If True, the compatible streams are limited
by the stream version computed from `stream`. If False, even the
modules with higher stream version are returned.
:param virtual_streams: List of virtual streams.
Modules are returned if they share one of the virtual streams.
:param states: List of states the returned compatible modules should
be in.
:return list: List of Modulemd objects.
"""
if not virtual_streams:
raise RuntimeError("Virtual stream list must not be empty")
name = base_module_mmd.get_module_name()
extra_args = {}
if stream_version_lte:
stream = base_module_mmd.get_stream_name()
stream_in_xyz_format = len(str(models.ModuleBuild.get_stream_version(
stream, right_pad=False))) >= 5
if not stream_in_xyz_format:
raise StreamNotXyz(
"Cannot get compatible base modules, because stream of resolved "
"base module %s:%s is not in x.y.z format." % (
base_module_mmd.get_module_name(), stream
))
stream_version = models.ModuleBuild.get_stream_version(stream)
extra_args["stream_version_lte"] = stream_version
extra_args["virtual_stream"] = virtual_streams
extra_args["states"] = _canonicalize_states(states)
modules = self._get_modules(name, None, **extra_args)
return self._modules_to_modulemds(modules, False)
def get_buildrequired_modulemds(self, name, stream, base_module_mmd):
"""
Returns modulemd metadata of all module builds with `name` and `stream` buildrequiring
base module defined by `base_module_nsvc` NSVC.
:param str name: Name of module to return.
:param str stream: Stream of module to return.
:param str base_module_nsvc: NSVC of base module which must be buildrequired by returned
modules.
:rtype: list
:return: List of modulemd metadata.
"""
# If user used --add-local-build, we do actually not care about base_module_nsvc,
# because the user directly asked us to use that module.
local_modules = models.ModuleBuild.local_modules(self.db_session, name, stream)
if local_modules:
return [m.mmd() for m in local_modules]
tag = base_module_mmd.get_xmd().get("mbs", {}).get("koji_tag_with_modules")
if tag:
# In case KojiResolver is enabled for this base module, ask Koji for list of
# Koji builds and then get the modulemd file from the MBS running in infra.
koji_builds = self.get_buildrequired_koji_builds(name, stream, base_module_mmd)
ret = []
for build in koji_builds:
version, context = build["release"].split(".")
ret += self.get_module_modulemds(name, stream, version, context, strict=True)
return ret
else:
modules = self._get_modules(
name, stream, base_module_br=base_module_mmd.get_nsvc())
return [load_mmd(module["modulemd"]) for module in modules]
def resolve_profiles(self, mmd, keys):
"""
:param mmd: Modulemd.ModuleStream instance of module
:param keys: list of modulemd installation profiles to include in
the result.
:return: Dictionary with keys set according to `keys` param and values
set to union of all components defined in all installation
profiles matching the key using the buildrequires.
If there are some modules loaded by load_local_builds(...), these
local modules will be considered when returning the profiles.
https://pagure.io/fm-orchestrator/issue/181
"""
results = {}
for key in keys:
results[key] = set()
for module_name, module_info in mmd.get_xmd()["mbs"]["buildrequires"].items():
local_modules = models.ModuleBuild.local_modules(
self.db_session, module_name, module_info["stream"])
if local_modules:
local_module = local_modules[0]
log.info("Using local module %r to resolve profiles.", local_module)
dep_mmd = local_module.mmd()
for key in keys:
profile = dep_mmd.get_profile(key)
if profile:
results[key] |= set(profile.get_rpms())
continue
# Find the dep in the built modules in MBS
module = self.get_module(
module_name,
module_info["stream"],
module_info["version"],
module_info["context"],
strict=True,
)
yaml = module["modulemd"]
dep_mmd = load_mmd(yaml)
# Take note of what rpms are in this dep's profile.
for key in keys:
profile = dep_mmd.get_profile(key)
if profile:
results[key] |= set(profile.get_rpms())
# Return the union of all rpms in all profiles of the given keys.
return results
def get_module_build_dependencies(
self, name=None, stream=None, version=None, context=None, mmd=None, strict=False
):
"""
Returns a dictionary of koji_tag:[mmd, ...] of all the dependencies of input module.
Although it is expected that single Koji tag always contain just single module build,
it does not have to be a true for Offline local builds which use the local repository
identifier as `koji_tag`.
:param name: a module's name (required if mmd is not set)
:param stream: a module's stream (required if mmd is not set)
:param version: a module's version (required if mmd is not set)
:param context: a module's context (required if mmd is not set)
:param mmd: uses the mmd instead of the name, stream, version
:param strict: Normally this function returns None if no module can be
found. If strict=True, then an UnprocessableEntity is raised.
:return: a mapping containing buildrequire modules info in key/value pairs,
where key is koji_tag and value is list of Modulemd.ModuleStream objects.
:rtype: dict(str, :class:`Modulemd.Module`)
"""
if mmd:
log.debug("get_module_build_dependencies(mmd=%r strict=%r)" % (mmd, strict))
elif any(x is None for x in [name, stream, version, context]):
raise RuntimeError("The name, stream, version, and/or context weren't specified")
else:
version = str(version)
log.debug(
"get_module_build_dependencies(%s, strict=%r)"
% (", ".join([name, stream, str(version), context]), strict)
)
log.long_running_start("Loading build dependencies")
# This is the set we're going to build up and return.
module_tags = {}
if mmd:
queried_mmd = mmd
else:
queried_module = self.get_module(name, stream, version, context, strict=strict)
yaml = queried_module["modulemd"]
if yaml:
queried_mmd = load_mmd(yaml)
else:
queried_mmd = None
if not queried_mmd or "buildrequires" not in queried_mmd.get_xmd().get("mbs", {}):
raise RuntimeError(
'The module "{0!r}" did not contain its modulemd or did not have '
"its xmd attribute filled out in MBS".format(queried_module)
)
buildrequires = queried_mmd.get_xmd()["mbs"]["buildrequires"]
# Queue up the next tier of deps that we should look at..
for name, details in buildrequires.items():
local_modules = models.ModuleBuild.local_modules(
self.db_session, name, details["stream"])
if local_modules:
for m in local_modules:
module_tags[m.koji_tag] = m.mmd()
continue
if "context" not in details:
details["context"] = models.DEFAULT_MODULE_CONTEXT
m = self.get_module(
name, details["stream"], details["version"], details["context"], strict=True)
if m["koji_tag"] in module_tags:
continue
# If the buildrequire is a meta-data only module with no Koji tag set, then just
# skip it
if m["koji_tag"] is None:
continue
module_tags.setdefault(m["koji_tag"], [])
module_tags[m["koji_tag"]].append(load_mmd(m["modulemd"]))
log.long_running_end("Loading build dependencies")
return module_tags
def resolve_requires(self, requires):
"""
Resolves the requires list of N:S or N:S:V:C to a dictionary with keys as
the module name and the values as a dictionary with keys of ref,
stream, version.
If there are some modules loaded by load_local_builds(...), these
local modules will be considered when resolving the requires. A RuntimeError
is raised on MBS lookup errors.
:param requires: a list of N:S or N:S:V:C strings
:return: a dictionary
"""
new_requires = {}
for nsvc in requires:
nsvc_splitted = nsvc.split(":")
if len(nsvc_splitted) == 2:
module_name, module_stream = nsvc_splitted
module_version = None
module_context = None
elif len(nsvc_splitted) == 4:
module_name, module_stream, module_version, module_context = nsvc_splitted
else:
raise ValueError(
"Only N:S or N:S:V:C is accepted by resolve_requires, got %s" % nsvc)
# Try to find out module dependency in the local module builds
# added by load_local_builds(...).
local_modules = models.ModuleBuild.local_modules(
self.db_session, module_name, module_stream)
if local_modules:
local_build = local_modules[0]
new_requires[module_name] = {
# The commit ID isn't currently saved in modules.yaml
"ref": None,
"stream": local_build.stream,
"version": local_build.version,
"context": local_build.context,
"koji_tag": local_build.koji_tag,
# No need to set filtered_rpms for local builds, because MBS
# filters the RPMs automatically when the module build is
# done.
"filtered_rpms": [],
}
continue
commit_hash = None
version = None
module = self.get_module(
module_name, module_stream, module_version, module_context, strict=True
)
if module.get("modulemd"):
mmd = load_mmd(module["modulemd"])
if mmd.get_xmd().get("mbs", {}).get("commit"):
commit_hash = mmd.get_xmd()["mbs"]["commit"]
if module.get("version"):
version = module["version"]
if version and commit_hash:
new_requires[module_name] = {
"ref": commit_hash,
"stream": module_stream,
"version": str(version),
"context": module["context"],
"koji_tag": module["koji_tag"],
}
else:
raise RuntimeError(
'The module "{0}" didn\'t contain both a commit hash and a'
" version in MBS".format(module_name)
)
# If the module is a base module, then import it in the database so that entries in
# the module_builds_to_module_buildrequires table can be created later on
if module_name in conf.base_module_names:
import_mmd(self.db_session, mmd)
return new_requires
def get_modulemd_by_koji_tag(self, tag):
resp = requests_session.get(self.mbs_prod_url, params={"koji_tag": tag, "verbose": True})
data = resp.json()
if data["items"]:
modulemd = data["items"][0]["modulemd"]
return load_mmd(modulemd)
else:
return None