Skip to content

Commit

Permalink
Add new "container" class ObsPackage to replace ContainerCrate
Browse files Browse the repository at this point in the history
  • Loading branch information
dcermak committed Oct 14, 2024
1 parent e2844dc commit 3dfb040
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 34 deletions.
61 changes: 33 additions & 28 deletions src/bci_build/package/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@
from bci_build.os_version import ALL_OS_LTSS_VERSIONS
from bci_build.os_version import RELEASED_OS_VERSIONS
from bci_build.os_version import OsVersion
from bci_build.package.obs_package import ObsPackageBase
from bci_build.registry import ApplicationCollectionRegistry
from bci_build.registry import Registry
from bci_build.registry import publish_registry
from bci_build.service import Service
from bci_build.templates import DOCKERFILE_TEMPLATE
from bci_build.templates import INFOHEADER_TEMPLATE
from bci_build.templates import KIWI_TEMPLATE
from bci_build.templates import SERVICE_TEMPLATE
from bci_build.util import write_to_file

_BASH_SET: str = "set -euo pipefail"
Expand Down Expand Up @@ -134,24 +134,17 @@ def _build_tag_prefix(os_version: OsVersion) -> str:
return "bci"


@dataclass
class BaseContainerImage(abc.ABC):
@dataclass(kw_only=True)
class BaseContainerImage(ObsPackageBase):
"""Base class for all Base Containers."""

#: Name of this image. It is used to generate the build tags, i.e. it
#: defines under which name this image is published.
name: str

#: The SLE service pack to which this package belongs
os_version: OsVersion

#: Human readable name that will be inserted into the image title and description
pretty_name: str

#: Optional a package_name, used for creating the package name on OBS or IBS in
# ``devel:BCI:SLE-15-SP$ver`` (on OBS) or ``SUSE:SLE-15-SP$ver:Update:BCI`` (on IBS)
package_name: str | None = None

#: Epoch to use for handling os_version downgrades
os_epoch: int | None = None

Expand Down Expand Up @@ -315,6 +308,8 @@ def publish_registry(self) -> Registry:
return self._publish_registry

def __post_init__(self) -> None:
super().__post_init__()

self.pretty_name = self.pretty_name.strip()

if not self.package_name:
Expand All @@ -328,11 +323,6 @@ def __post_init__(self) -> None:
"Cannot specify both a custom_end and a config.sh script! Use just config_sh_script."
)

if self.build_recipe_type is None:
self.build_recipe_type = (
BuildType.KIWI if self.os_version == OsVersion.SP3 else BuildType.DOCKER
)

if not self.maintainer:
self.maintainer = (
"openSUSE (https://www.opensuse.org/)"
Expand Down Expand Up @@ -363,12 +353,6 @@ def prepare_template(self) -> None:

pass

@property
@abc.abstractmethod
def uid(self) -> str:
"""unique identifier of this image, either its name or ``$name-$tag_version``."""
pass

@property
@abc.abstractmethod
def oci_version(self) -> str:
Expand Down Expand Up @@ -1046,12 +1030,34 @@ def kiwi_additional_tags(self) -> str | None:

return ",".join(extra_tags) if extra_tags else None

async def write_files_to_folder(self, dest: str) -> list[str]:
@property
def services(self) -> tuple[Service, ...]:
if not self.replacements_via_service:
return ()

if self.build_recipe_type == BuildType.DOCKER:
if self.build_flavor:
default_file_name = f"Dockerfile.{self.build_flavor}"
else:
default_file_name = "Dockerfile"
elif self.build_recipe_type == BuildType.KIWI:
default_file_name = f"{self.package_name}.kiwi"
else:
raise ValueError(f"invalid build recipe type: {self.build_recipe_type}")

return tuple(
replacement.to_service(default_file_name)
for replacement in self.replacements_via_service
)

async def write_files_to_folder(
self, dest: str, *, with_service_file: bool = True
) -> list[str]:
"""Writes all files required to build this image into the destination folder and
returns the filenames (not full paths) that were written to the disk.
"""
files = ["_service"]
files = ["_service"] if with_service_file else []
tasks = []

self.prepare_template()
Expand Down Expand Up @@ -1106,9 +1112,8 @@ async def write_file_to_dest(fname: str, contents: str | bytes) -> None:
tasks.append(write_file_to_dest(mname, self.crate.multibuild(self)))
files.append(mname)

tasks.append(
write_file_to_dest("_service", SERVICE_TEMPLATE.render(image=self))
)
if with_service_file:
tasks.append(self._write_service_file(dest))

changes_file_name = self.package_name + ".changes"
if not (Path(dest) / changes_file_name).exists():
Expand Down Expand Up @@ -1444,7 +1449,7 @@ def generate_disk_size_constraints(size_gb: int) -> str:
from .rust import RUST_CONTAINERS # noqa: E402
from .spack import SPACK_CONTAINERS # noqa: E402

ALL_CONTAINER_IMAGE_NAMES: dict[str, BaseContainerImage] = {
ALL_CONTAINER_IMAGE_NAMES: dict[str, ObsPackageBase] = {
f"{bci.uid}-{bci.os_version.pretty_print.lower()}": bci
for bci in (
*BASE_CONTAINERS,
Expand Down Expand Up @@ -1491,7 +1496,7 @@ def generate_disk_size_constraints(size_gb: int) -> str:

SORTED_CONTAINER_IMAGE_NAMES = sorted(
ALL_CONTAINER_IMAGE_NAMES,
key=lambda bci: f"{ALL_CONTAINER_IMAGE_NAMES[bci].os_version}-{ALL_CONTAINER_IMAGE_NAMES[bci].name}",
key=lambda bci: f"{ALL_CONTAINER_IMAGE_NAMES[bci].os_version}-{ALL_CONTAINER_IMAGE_NAMES[bci].uid}",
)


Expand Down
217 changes: 217 additions & 0 deletions src/bci_build/package/obs_package.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"""This module contains the classes for the ObsPackage container, which bundles
together multiple base container images into a single package.
"""

import abc
import asyncio
import os.path
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from io import BytesIO
from typing import TYPE_CHECKING
from typing import Coroutine
from typing import Sequence

from bci_build.container_attributes import BuildType
from bci_build.os_version import OsVersion
from bci_build.service import Service
from bci_build.util import write_to_file

if TYPE_CHECKING:
from bci_build.package import BaseContainerImage


@dataclass(kw_only=True)
class ObsPackageBase(abc.ABC):
"""Abstract base class of the ObsPackage and the BaseContainerImage."""

#: The name of the package in the Build Service
package_name: str | None = None

#: The OS version to which this package belongs
os_version: OsVersion

#: Define whether this container image is built using docker or kiwi.
#: If not set, then the build type will default to docker from SP4 onwards.
build_recipe_type: BuildType | None = None

def __post_init__(self) -> None:
if self.build_recipe_type is None:
self.build_recipe_type = (
BuildType.KIWI if self.os_version == OsVersion.SP3 else BuildType.DOCKER
)

@property
@abc.abstractmethod
def uid(self) -> str:
"""unique identifier of this package, either its name or ``$name-$tag_version``."""

@property
@abc.abstractmethod
def services(self) -> tuple[Service, ...]:
"""The source services that are part of this package."""

@property
@abc.abstractmethod
def title(self) -> str:
"""The title of this package."""

@property
@abc.abstractmethod
def description(self) -> str:
"""The description of this package."""

@abc.abstractmethod
async def write_files_to_folder(
self, dest: str, *, with_service_file: bool = True
) -> list[str]:
"""Write all files belonging to this package into the directory
``dest``.
If ``with_service_file`` is ``False``, then the :file:`_service` will
not be written to ``dest``.
"""

@property
def _service_file_contents(self) -> str:
root = ET.Element("services")
for service in [
Service(name=f"{self.build_recipe_type}_label_helper"),
Service(name="kiwi_metainfo_helper"),
] + list(self.services):
root.append(service.as_xml_element())

tree = ET.ElementTree(root)
ET.indent(tree)
io = BytesIO()
tree.write(io, encoding="utf-8")
io.seek(0)
return io.read().decode("utf-8")

async def _write_service_file(self, dest: str) -> list[str]:
await write_to_file(os.path.join(dest, "_service"), self._service_file_contents)
return ["_service"]


@dataclass(kw_only=True)
class ObsPackage(ObsPackageBase):
"""ObsPackage is a container for combining multiple container images with
different build flavors into a single package.
"""

bcis: list["BaseContainerImage"]

#: Optional custom title of this package. If unset, then the title of the
#: first bci is used.
custom_title: str | None = None

#: Optional custom description of this package. If unset, then the
#: description of the first bci is used.
custom_description: str | None = None

@staticmethod
def from_bcis(
bcis: Sequence["BaseContainerImage"], package_name: str | None = None
) -> "ObsPackage":
pkg_names: set[str] = set()
os_versions: set[OsVersion] = set()
multibuild_flavors: list[str | None] = []

for bci in bcis:
if bci.package_name:
pkg_names.add(bci.package_name)
os_versions.add(bci.os_version)
multibuild_flavors.append(bci.build_flavor)

if len(pkg_names) != 1 and not package_name:
raise ValueError(f"got a non unique package name: {pkg_names}")

if len(os_versions) != 1:
raise ValueError(f"got a non unique os_version: {os_versions}")

if len(set(multibuild_flavors)) != len(multibuild_flavors):
raise ValueError(
f"The multibuild flavors are not unique: {multibuild_flavors}"
)

if not package_name:
package_name = pkg_names.pop()

return ObsPackage(
package_name=package_name, os_version=os_versions.pop(), bcis=list(bcis)
)

def __post_init__(self) -> None:
super().__post_init__()

# we only support Dockerfile based multibuild at the moment
self.build_recipe_type = BuildType.DOCKER

if not self.package_name:
raise ValueError("A package name must be provided")

for bci in self.bcis:
if not bci.build_flavor:
raise ValueError(f"Container {bci.name} has no build flavor defined")

if bci.build_recipe_type != BuildType.DOCKER:
raise ValueError(f"Container {bci.name} is not built from a Dockerfile")

@property
def uid(self) -> str:
return self.package_name

@property
def services(self) -> tuple[Service, ...]:
return tuple(service for bci in self.bcis for service in bci.services)

async def write_files_to_folder(
self, dest: str, *, with_service_file=True
) -> list[str]:
async def write_file_to_dest(fname: str, contents: str) -> list[str]:
await write_to_file(os.path.join(dest, fname), contents)
return [fname]

tasks: list[Coroutine[None, None, list[str]]] = []
for bci in self.bcis:
tasks.append(bci.write_files_to_folder(dest, with_service_file=False))

tasks.append(write_file_to_dest("Dockerfile", self.default_dockerfile))
tasks.append(write_file_to_dest("_multibuild", self.multibuild))
if with_service_file:
tasks.append(self._write_service_file(dest))

return [f for file_list in await asyncio.gather(*tasks) for f in file_list]

@property
def title(self) -> str:
return self.custom_title or self.bcis[0].title

@property
def description(self) -> str:
return self.custom_description or self.bcis[0].description

@property
def default_dockerfile(self) -> str:
"""Return a default :file:`Dockerfile` to disable the build for the
default flavor.
"""
return """#!ExclusiveArch: do-not-build
# For this container we only build the Dockerfile.$flavor builds.
"""

@property
def multibuild(self) -> str:
"""Return the contents of the :file:`_multibuild` file for this
package.
"""
flavors: str = "\n".join(
" " * 4 + f"<package>{pkg.build_flavor}</package>" for pkg in self.bcis
)
return f"<multibuild>\n{flavors}\n</multibuild>"
Loading

0 comments on commit 3dfb040

Please sign in to comment.