mpp: combine depsolve and import into mpp.py
Rewrite image pre-processor to single tool so that it is easier to use. Now also supports `ignore-weak-deps` when dep-solving and supports relative paths for local files. Also create a symlink to the osbuild package, so that the tools can be run from the source checkout and have access to the osbuild package.
This commit is contained in:
parent
e5d599d8ee
commit
ab453bf81a
2 changed files with 476 additions and 0 deletions
475
tools/mpp.py
Executable file
475
tools/mpp.py
Executable file
|
|
@ -0,0 +1,475 @@
|
|||
#!/usr/bin/python3
|
||||
|
||||
"""Manifest-Pre-Processor
|
||||
|
||||
This manifest-pre-processor takes a path to a manifest, loads it,
|
||||
runs various pre-processing options and then produces a resultant manfest, written
|
||||
to a specified filename (or stdout if filename is "-").
|
||||
|
||||
Manifest format version "1" and "2" are supported.
|
||||
|
||||
Pipeline Import:
|
||||
|
||||
This tool imports a pipeline from another file and inserts it into a manifest
|
||||
at the same position the import instruction is located. Sources from the
|
||||
imported manifest are merged with the existing sources.
|
||||
|
||||
The parameters for this pre-processor for format version "1" look like this:
|
||||
|
||||
```
|
||||
...
|
||||
"mpp-import-pipeline": {
|
||||
"path": "./manifest.json"
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
The parameters for this pre-processor for format version "2" look like this:
|
||||
|
||||
```
|
||||
...
|
||||
"mpp-import-pipeline": {
|
||||
"path": "./manifest.json",
|
||||
"id:" "build"
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
Depsolving:
|
||||
|
||||
This tool adjusts the `org.osbuild.rpm` stage. It consumes the `mpp-depsolve`
|
||||
option and produces a package-list and source-entries.
|
||||
|
||||
It supports version "1" and version "2" of the manifest description format.
|
||||
|
||||
The parameters for this pre-processor, version "1", look like this:
|
||||
|
||||
```
|
||||
...
|
||||
{
|
||||
"name": "org.osbuild.rpm",
|
||||
...
|
||||
"options": {
|
||||
...
|
||||
"mpp-depsolve": {
|
||||
"architecture": "x86_64",
|
||||
"module-platform-id": "f32",
|
||||
"baseurl": "http://mirrors.kernel.org/fedora/releases/32/Everything/x86_64/os",
|
||||
"repos": [
|
||||
{
|
||||
"id": "default",
|
||||
"metalink": "https://mirrors.fedoraproject.org/metalink?repo=fedora-32&arch=$basearch"
|
||||
}
|
||||
],
|
||||
"packages": [
|
||||
"@core",
|
||||
"dracut-config-generic",
|
||||
"grub2-pc",
|
||||
"kernel"
|
||||
],
|
||||
"excludes": [
|
||||
(optional excludes)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
The parameters for this pre-processor, version "2", look like this:
|
||||
|
||||
```
|
||||
...
|
||||
{
|
||||
"name": "org.osbuild.rpm",
|
||||
...
|
||||
"inputs": {
|
||||
packages: {
|
||||
"mpp-depsolve": {
|
||||
see above
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import pathlib
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import collections
|
||||
import dnf
|
||||
import hawkey
|
||||
|
||||
from osbuild.util.rhsm import Subscriptions
|
||||
|
||||
|
||||
def element_enter(element, key, default):
|
||||
if key not in element:
|
||||
element[key] = default.copy()
|
||||
return element[key]
|
||||
|
||||
host_subscriptions = None
|
||||
|
||||
# Expand non-uris as paths relative to basedir into a file:/// uri
|
||||
def _dnf_expand_baseurl(baseurl, basedir):
|
||||
try:
|
||||
result = urllib.parse.urlparse(baseurl)
|
||||
if not result.scheme:
|
||||
path = basedir.joinpath(baseurl)
|
||||
return path.as_uri()
|
||||
except:
|
||||
pass
|
||||
return baseurl
|
||||
|
||||
def _dnf_repo(conf, desc, basedir):
|
||||
repo = dnf.repo.Repo(desc["id"], conf)
|
||||
url = None
|
||||
url_keys = ["baseurl", "metalink", "mirrorlist"]
|
||||
skip_keys = ["id", "secrets"]
|
||||
supported = ["baseurl", "metalink", "mirrorlist",
|
||||
"enabled", "metadata_expire", "gpgcheck", "username", "password", "priority",
|
||||
"sslverify", "sslcacert", "sslclientkey", "sslclientcert"]
|
||||
|
||||
for key in desc.keys():
|
||||
if key in skip_keys:
|
||||
continue # We handled this already
|
||||
|
||||
if key in url_keys:
|
||||
url = desc[key]
|
||||
if key in supported:
|
||||
value = desc[key]
|
||||
if key == "baseurl":
|
||||
value = _dnf_expand_baseurl(value, basedir)
|
||||
setattr(repo, key, value)
|
||||
else:
|
||||
raise ValueError(f"Unknown repo config option {key}")
|
||||
if url == None:
|
||||
raise ValueError("repo description does not contain baseurl, metalink, or mirrorlist keys")
|
||||
|
||||
global host_subscriptions
|
||||
secrets = None
|
||||
if "secrets" in desc:
|
||||
secrets_desc = desc["secrets"]
|
||||
if "name" in secrets_desc and secrets_desc["name"] == "org.osbuild.rhsm":
|
||||
try:
|
||||
# rhsm secrets only need to be retrieved once and can then be reused
|
||||
if host_subscriptions is None:
|
||||
host_subscriptions = Subscriptions.from_host_system()
|
||||
secrets = host_subscriptions.get_secrets(url)
|
||||
except RuntimeError as e:
|
||||
raise ValueError(f"Error gettting secrets: {e.args[0]}")
|
||||
|
||||
if secrets:
|
||||
if "ssl_ca_cert" in secrets:
|
||||
repo.sslcacert = secrets["ssl_ca_cert"]
|
||||
if "ssl_client_key" in secrets:
|
||||
repo.sslclientkey = secrets["ssl_client_key"]
|
||||
if "ssl_client_cert" in secrets:
|
||||
repo.sslclientcert = secrets["ssl_client_cert"]
|
||||
|
||||
return repo
|
||||
|
||||
def _dnf_base(mpp_depsolve, persistdir, cachedir, basedir):
|
||||
arch = mpp_depsolve["architecture"]
|
||||
module_platform_id = mpp_depsolve["module-platform-id"]
|
||||
ignore_weak_deps = bool(mpp_depsolve.get("ignore-weak-deps", False))
|
||||
repos = mpp_depsolve.get("repos", [])
|
||||
|
||||
base = dnf.Base()
|
||||
if cachedir:
|
||||
base.conf.cachedir = cachedir
|
||||
base.conf.config_file_path = "/dev/null"
|
||||
base.conf.module_platform_id = module_platform_id
|
||||
base.conf.persistdir = persistdir
|
||||
base.conf.substitutions['arch'] = arch
|
||||
base.conf.substitutions['basearch'] = dnf.rpm.basearch(arch)
|
||||
base.conf.install_weak_deps = not ignore_weak_deps
|
||||
|
||||
for repo in repos:
|
||||
base.repos.add(_dnf_repo(base.conf, repo, basedir))
|
||||
|
||||
base.fill_sack(load_system_repo=False)
|
||||
return base
|
||||
|
||||
def _dnf_resolve(mpp_depsolve, basedir):
|
||||
deps = []
|
||||
|
||||
repos = mpp_depsolve.get("repos", [])
|
||||
packages = mpp_depsolve.get("packages", [])
|
||||
excludes = mpp_depsolve.get("excludes", [])
|
||||
baseurl = mpp_depsolve.get("baseurl")
|
||||
|
||||
baseurls = {
|
||||
repo["id"]: repo.get("baseurl", baseurl) for repo in repos
|
||||
}
|
||||
secrets = {
|
||||
repo["id"]: repo.get("secrets", None) for repo in repos
|
||||
}
|
||||
|
||||
if len(packages) > 0:
|
||||
with tempfile.TemporaryDirectory() as persistdir:
|
||||
base = _dnf_base(mpp_depsolve, persistdir, dnf_cache, basedir)
|
||||
base.install_specs(packages, exclude=excludes)
|
||||
base.resolve()
|
||||
|
||||
for tsi in base.transaction:
|
||||
if tsi.action not in dnf.transaction.FORWARD_ACTIONS:
|
||||
continue
|
||||
|
||||
checksum_type = hawkey.chksum_name(tsi.pkg.chksum[0])
|
||||
checksum_hex = tsi.pkg.chksum[1].hex()
|
||||
|
||||
path = tsi.pkg.relativepath
|
||||
reponame = tsi.pkg.reponame
|
||||
base = _dnf_expand_baseurl(baseurls[reponame], basedir)
|
||||
# dep["path"] often starts with a "/", even though it's meant to be
|
||||
# relative to `baseurl`. Strip any leading slashes, but ensure there's
|
||||
# exactly one between `baseurl` and the path.
|
||||
url = urllib.parse.urljoin(base + "/", path.lstrip("/"))
|
||||
secret = secrets[reponame]
|
||||
|
||||
pkg = {
|
||||
"checksum": f"{checksum_type}:{checksum_hex}",
|
||||
"name": tsi.pkg.name,
|
||||
"url": url,
|
||||
}
|
||||
if secret:
|
||||
pkg["secrets"] = secret
|
||||
deps.append(pkg)
|
||||
|
||||
return deps
|
||||
|
||||
class ManifestFile:
|
||||
@staticmethod
|
||||
def load(path):
|
||||
with open(path) as f:
|
||||
# We use OrderedDict to preserve key order (for python < 3.6)
|
||||
data = json.load(f, object_pairs_hook=collections.OrderedDict)
|
||||
version = int(data.get("version", "1"))
|
||||
if version == 1:
|
||||
return ManifestFileV1(path, data)
|
||||
elif version == 2:
|
||||
return ManifestFileV2(path, data)
|
||||
raise ValueError(f"Unknown manfest version {version}")
|
||||
|
||||
def __init__(self, path, root, version):
|
||||
self.path = pathlib.Path(path)
|
||||
self.basedir = self.path.parent
|
||||
self.root = root
|
||||
self.version = version
|
||||
self.sources = element_enter(self.root, "sources", {})
|
||||
|
||||
def load_import(self, path):
|
||||
m = ManifestFile.load(self.basedir.joinpath(path))
|
||||
if m.version != self.version:
|
||||
raise ValueError(f"Incompatible manifest version {m.version}")
|
||||
return m
|
||||
|
||||
|
||||
def write(self, file, sort_keys=False):
|
||||
json.dump(self.root, file, indent=2, sort_keys=sort_keys)
|
||||
file.write("\n")
|
||||
|
||||
class ManifestFileV1(ManifestFile):
|
||||
def __init__(self, path, data):
|
||||
super(ManifestFileV1, self).__init__(path, data, 1)
|
||||
self.pipeline = element_enter(self.root, "pipeline", {})
|
||||
|
||||
files = element_enter(self.sources, "org.osbuild.files", {})
|
||||
self.source_urls = element_enter(files, "urls", {})
|
||||
|
||||
def _process_import(self, build):
|
||||
mpp = build.get("mpp-import-pipeline")
|
||||
if not mpp:
|
||||
return
|
||||
|
||||
path = mpp["path"]
|
||||
imp = self.load_import(path)
|
||||
|
||||
# We only support importing manifests with URL sources. Other sources are
|
||||
# not supported, yet. This can be extended in the future, but we should
|
||||
# maybe rather try to make sources generic (and repeatable?), so we can
|
||||
# deal with any future sources here as well.
|
||||
assert list(imp.sources.keys()) == ["org.osbuild.files"]
|
||||
|
||||
# We import `sources` from the manifest, as well as a pipeline description
|
||||
# from the `pipeline` entry. Make sure nothing else is in the manifest, so
|
||||
# we do not accidentally miss new features.
|
||||
assert list(imp.root.keys()).sort() == ["pipeline", "sources"].sort()
|
||||
|
||||
# Now with everything imported and verified, we can merge the pipeline back
|
||||
# into the original manifest. We take all URLs and merge them in the pinned
|
||||
# url-array, and then we take the pipeline and simply override any original
|
||||
# pipeline at the position where the import was declared.
|
||||
|
||||
self.source_urls.update(imp.source_urls)
|
||||
|
||||
build["pipeline"] = imp.pipeline
|
||||
del(build["mpp-import-pipeline"])
|
||||
|
||||
def process_imports(self):
|
||||
current = self.root
|
||||
while current:
|
||||
self._process_import(current)
|
||||
current = current.get("pipeline", {}).get("build")
|
||||
|
||||
def _process_depsolve(self, stage):
|
||||
if stage.get("name", "") != "org.osbuild.rpm":
|
||||
return
|
||||
options = stage.get("options")
|
||||
if not options:
|
||||
return
|
||||
mpp = options.get("mpp-depsolve")
|
||||
if not mpp:
|
||||
return
|
||||
|
||||
del(options["mpp-depsolve"])
|
||||
|
||||
packages = element_enter(options, "packages", [])
|
||||
|
||||
deps = _dnf_resolve(mpp, self.basedir)
|
||||
for dep in deps:
|
||||
checksum = dep["checksum"]
|
||||
|
||||
packages.append(checksum)
|
||||
|
||||
data = { "url": dep["url"] }
|
||||
if "secrets" in dep:
|
||||
data["secrets"] = dep["secrets"]
|
||||
self.source_urls[checksum] = data
|
||||
|
||||
def process_depsolves(self, pipeline = None):
|
||||
if pipeline == None:
|
||||
pipeline = self.pipeline
|
||||
stages = element_enter(pipeline, "stages", [])
|
||||
for stage in stages:
|
||||
self._process_depsolve(stage)
|
||||
build = pipeline.get("build")
|
||||
if build:
|
||||
if "pipeline" in build:
|
||||
self.process_depsolves(build["pipeline"])
|
||||
|
||||
class ManifestFileV2(ManifestFile):
|
||||
def __init__(self, path, data):
|
||||
super(ManifestFileV2, self).__init__(path, data, 2)
|
||||
self.pipelines = element_enter(self.root, "pipelines", {})
|
||||
|
||||
files = element_enter(self.sources, "org.osbuild.curl", {})
|
||||
self.source_urls = element_enter(files, "items", {})
|
||||
|
||||
def get_pipeline_by_name(self, name):
|
||||
for pipeline in self.pipelines:
|
||||
if pipeline["name"] == name:
|
||||
return pipeline
|
||||
|
||||
raise ValueError(f"Pipeline '{name}' not found in {self.path}")
|
||||
|
||||
def _process_import(self, pipeline):
|
||||
mpp = pipeline.get("mpp-import-pipeline")
|
||||
if not mpp:
|
||||
return
|
||||
|
||||
path = mpp["path"]
|
||||
imp = self.load_import(path)
|
||||
|
||||
for source, desc in imp.sources.items():
|
||||
target = self.sources.get(source)
|
||||
if not target:
|
||||
# new source, just copy everything
|
||||
self.sources[source] = desc
|
||||
continue
|
||||
|
||||
if desc.get("options"):
|
||||
options = element_enter(target, "options", {})
|
||||
options.update(desc["options"])
|
||||
|
||||
items = element_enter(target, "items", {})
|
||||
items.update(desc.get("items", {}))
|
||||
|
||||
del(pipeline["mpp-import-pipeline"])
|
||||
target = imp.get_pipeline_by_name(mpp["id"])
|
||||
pipeline.update(target)
|
||||
|
||||
def process_imports(self):
|
||||
for pipeline in self.pipelines:
|
||||
self._process_import(pipeline)
|
||||
|
||||
def _process_depsolve(self, stage):
|
||||
if stage.get("type", "") != "org.osbuild.rpm":
|
||||
return
|
||||
inputs = element_enter(stage, "inputs", {})
|
||||
packages = element_enter(inputs, "packages", {})
|
||||
mpp = packages.get("mpp-depsolve")
|
||||
if not mpp:
|
||||
return
|
||||
|
||||
refs = element_enter(packages, "references", {})
|
||||
|
||||
deps = _dnf_resolve(mpp, self.basedir)
|
||||
for dep in deps:
|
||||
checksum = dep["checksum"]
|
||||
refs[checksum] = {}
|
||||
|
||||
data = { "url": dep["url"] }
|
||||
if "secrets" in dep:
|
||||
data["secrets"] = dep["secrets"]
|
||||
self.source_urls[checksum] = data
|
||||
|
||||
del(packages["mpp-depsolve"])
|
||||
|
||||
def process_depsolves(self):
|
||||
for pipeline in self.pipelines:
|
||||
stages = element_enter(pipeline, "stages", [])
|
||||
for stage in stages:
|
||||
self._process_depsolve(stage)
|
||||
|
||||
dnf_cache = None
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Manifest pre processor")
|
||||
parser.add_argument(
|
||||
"--dnf-cache",
|
||||
metavar="PATH",
|
||||
type=os.path.abspath,
|
||||
default=None,
|
||||
help="Path to DNF cache-directory to use",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sort-keys",
|
||||
dest="sort_keys",
|
||||
action='store_true',
|
||||
help="Sort keys in generated json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"src",
|
||||
metavar="SRCPATH",
|
||||
help="Input manifest",
|
||||
)
|
||||
parser.add_argument(
|
||||
"dst",
|
||||
metavar="DESTPATH",
|
||||
help="Output manifest",
|
||||
)
|
||||
|
||||
args = parser.parse_args(sys.argv[1:])
|
||||
|
||||
dnf_cache = args.dnf_cache
|
||||
|
||||
m = ManifestFile.load(args.src)
|
||||
|
||||
# First resolve all imports
|
||||
m.process_imports()
|
||||
|
||||
m.process_depsolves()
|
||||
|
||||
with sys.stdout if args.dst == "-" else open(args.dst, "w") as f:
|
||||
m.write(f, args.sort_keys)
|
||||
1
tools/osbuild
Symbolic link
1
tools/osbuild
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../osbuild
|
||||
Loading…
Add table
Add a link
Reference in a new issue