debian-forge/assemblers/org.osbuild.oci-archive
Simon de Vlieger d60690ce46 tox: add tox
`tox` is a standard testing tool for Python projects, this allows you to
test locally with all your installed Python version with the following
command:

`tox -m test -p all`

To run the tests in parallel for all supported Python versions.

To run linters or type analysis:

```
tox -m lint -p all
tox -m type -p all
```

This commit *also* disables the `import-error` warning from `pylint`,
not all Python versions have the system-installed Python libraries
available and they can't be fetched from PyPI.

Some linters have been added and the general order linters run in has
been changed. This allows for quicker test failure when running
`tox -m lint`. As a consequence the `test_pylint` test has been removed
as it's role can now be fulfilled by `tox`.

Other assorted linter fixes due to newer versions:
- use a str.join method (`consider-using-join`)
- fix various (newer) mypy and pylint issues
- comments starting with `#` and no space due to `autopep8`

This also changes our CI to use the new `tox` setup and on top of that
pins the versions of linters used. This might move into separate
requirements.txt files later on to allow for easier updating of those
dependencies.
2023-08-01 15:01:13 +02:00

283 lines
6.7 KiB
Python
Executable file

#!/usr/bin/python3
"""
Assemble an OCI image archive
Assemble an Open Container Initiative[1] image[2] archive, i.e. a
tarball whose contents is in the OCI image layout.
Currently the only required options are `filename` and `architecture`.
The execution parameters for the image, which then should form the base
for the container, can be given via `config`. They have the same format
as the `config` option for the "OCI Image Configuration" (see [2]),
except those that map to the "Go type map[string]struct{}", which are
represented as array of strings.
The final resulting tarball, aka a "oci-archive", can be imported via
podman[3] with `podman pull oci-archive:<archive>`.
[1] https://www.opencontainers.org/
[2] https://github.com/opencontainers/image-spec/
[3] https://podman.io/
"""
import datetime
import json
import os
import subprocess
import sys
import tempfile
import osbuild.api
DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
SCHEMA = """
"additionalProperties": false,
"required": ["architecture", "filename"],
"properties": {
"architecture": {
"description": "The CPU architecture of the image",
"type": "string"
},
"filename": {
"description": "Resulting image filename",
"type": "string"
},
"config": {
"description": "The execution parameters",
"type": "object",
"additionalProperties": false,
"properties": {
"Cmd": {
"type": "array",
"default": ["sh"],
"items": {
"type": "string"
}
},
"Entrypoint": {
"type": "array",
"items": {
"type": "string"
}
},
"Env": {
"type": "array",
"default": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"items": {
"type": "string"
}
},
"ExposedPorts": {
"type": "array",
"items": {
"type": "string"
}
},
"User": {
"type": "string"
},
"Labels": {
"type": "object",
"additionalProperties": true
},
"StopSiganl": {
"type": "string"
},
"Volumes": {
"type": "array",
"items": {
"type": "string"
}
},
"WorkingDir": {
"type": "string"
}
}
}
}
"""
MEDIA_TYPES = {
"layer": "application/vnd.oci.image.layer.v1.tar",
"manifest": "application/vnd.oci.image.manifest.v1+json",
"config": "application/vnd.oci.image.config.v1+json"
}
def sha256sum(path: str) -> str:
ret = subprocess.run(["sha256sum", path],
stdout=subprocess.PIPE,
encoding="utf8",
check=True)
return ret.stdout.strip().split(" ")[0]
def blobs_add_file(blobs: str, path: str, mtype: str):
digest = sha256sum(path)
size = os.stat(path).st_size
os.rename(path, os.path.join(blobs, digest))
info = {
"digest": "sha256:" + digest,
"size": size,
"mediaType": MEDIA_TYPES[mtype]
}
print(f"blobs: +{mtype} ({size}, {digest})")
return info
def blobs_add_json(blobs: str, js: str, mtype: str):
js_file = os.path.join(blobs, "temporary.js")
with open(js_file, "w", encoding="utf8") as f:
json.dump(js, f)
return blobs_add_file(blobs, js_file, mtype)
def blobs_add_layer(blobs: str, tree: str):
compression = "gzip"
layer_file = os.path.join(blobs, "layer.tar")
command = [
"tar",
"--no-selinux",
"--acls",
"--xattrs",
"-cf", layer_file,
"-C", tree,
] + os.listdir(tree)
print("creating layer")
subprocess.run(command,
stdout=subprocess.DEVNULL,
check=True)
digest = "sha256:" + sha256sum(layer_file)
print("compressing layer")
suffix = ".compressed"
subprocess.run([compression,
"-S", suffix,
layer_file],
stdout=subprocess.DEVNULL,
check=True)
layer_file += suffix
info = blobs_add_file(blobs, layer_file, "layer")
info["mediaType"] += "+" + compression
return digest, info
def config_from_options(options):
command = options.get("Cmd", ["sh"])
env = options.get("Env", ["PATH=" + DEFAULT_PATH])
config = {
"Env": env,
"Cmd": command
}
for name in ["Entrypoint", "User", "Labels", "StopSignal", "WorkingDir"]:
item = options.get(name)
if item:
config[name] = item
for name in ["ExposedPorts", "Volumes"]:
item = options.get(name)
if item:
config[name] = {x: {} for x in item}
print(config)
return config
def create_oci_dir(tree, output_dir, options):
architecture = options["architecture"]
config = {
"created": datetime.datetime.utcnow().isoformat() + "Z",
"architecture": architecture,
"os": "linux",
"config": config_from_options(options["config"]),
"rootfs": {
"type": "layers",
"diff_ids": []
}
}
manifest = {
"schemaVersion": 2,
"config": None,
"layers": []
}
index = {
"schemaVersion": 2,
"manifests": []
}
blobs = os.path.join(output_dir, "blobs", "sha256")
os.makedirs(blobs)
# layers / rootfs
digest, info = blobs_add_layer(blobs, tree)
config["rootfs"]["diff_ids"] = [digest]
manifest["layers"].append(info)
# write config
info = blobs_add_json(blobs, config, "config")
manifest["config"] = info
# manifest
info = blobs_add_json(blobs, manifest, "manifest")
index["manifests"].append(info)
# index
print("writing index")
with open(os.path.join(output_dir, "index.json"), "w", encoding="utf8") as f:
json.dump(index, f)
# oci-layout tag
with open(os.path.join(output_dir, "oci-layout"), "w", encoding="utf8") as f:
json.dump({"imageLayoutVersion": "1.0.0"}, f)
def main(tree, output_dir, options):
filename = options["filename"]
with tempfile.TemporaryDirectory(dir=output_dir) as tmpdir:
workdir = os.path.join(tmpdir, "output")
os.makedirs(workdir)
create_oci_dir(tree, workdir, options)
command = [
"tar",
"--remove-files",
"-cf", os.path.join(output_dir, filename),
f"--directory={workdir}",
] + os.listdir(workdir)
print("creating final archive")
subprocess.run(command,
stdout=subprocess.DEVNULL,
check=True)
if __name__ == '__main__':
args = osbuild.api.arguments()
args_input = args["inputs"]["tree"]["path"]
args_output = args["tree"]
r = main(args_input, args_output, args["options"])
sys.exit(r)