On 11/10/2026 10:54 pm, Guillaume Tucker wrote:
Add a --registry / -R command line argument to the container tool to
specify an altnerative image registry and support it in the TOML
configuration file accordingly.
This uses a mechanism based on regular expressions to parse the
container registry and image names and construct the final one based
on a set of priority rules. A registry can be included directly in an
image name; an empty string means a local image. It can then be
overridden by explicitly specifying a registry name via the CLI option
or the TOML file, with the CLI option taking precedence as always.
Signed-off-by: Guillaume Tucker <[email protected]>
---
scripts/container | 126 ++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 117 insertions(+), 9 deletions(-)
diff --git a/scripts/container b/scripts/container
index a3a55180d6ed..de72725656a8 100755
--- a/scripts/container
+++ b/scripts/container
@@ -10,6 +10,7 @@ import dataclasses
import logging
import os
import pathlib
+import re
import shutil
import subprocess
import sys
@@ -17,6 +18,89 @@ import tomllib
import uuid
+def _init_registry_regex():
+ domain = r'[{anum}][{anum}-]*\.[{anum}\.-]*[{anum}]'.format(anum=r'a-z0-9')
Sashiko wrote:
> Does this hardcoded requirement for a literal dot in the domain
> regex prevent the use of single-component hosts like localhost?
That's fair enough, a plain host can be used as long as a port number
is also provided. So localhost/image wouldn't work as it would be
considered part of the image path, but localhost:5000/image is valid.
I'll fix this in a v2.
+ ipv6 = r'\[[a-fA-F0-9\:]+\]'
+ port = r'[\d]{1,5}'
+ return rf'((?P<domain>{domain})|(?P<ipv6>{ipv6}))(\:(?P<port>{port}))?'
+
+
+def _init_image_regex():
+ step = r'([{anum}]|[{anum}][a-z0-9_\.\-]*[{anum}])'.format(anum=r'a-z0-9')
+ tag = r'[a-zA-Z0-9_][a-zA-Z0-9_\.\-\/]{0,127}'
+ return rf'(?P<path>{step}(\/{step})*)(\:(?P<tag>{tag}))?'
+
+
+REGISTRY_REGEX = _init_registry_regex()
+IMAGE_REGEX = _init_image_regex()
+
+
+class ImageParserMixin:
+ """Helper mixin to parse container image names"""
+
+ @classmethod
+ def from_fields(cls, fields):
+ """Get an object using a valid subset of fields or None"""
+ kwargs = {}
+ for field in dataclasses.fields(cls):
+ if value := fields.get(field.name):
+ kwargs[field.name] = value
+ return cls(**kwargs) if kwargs else None
+
+ @classmethod
+ def from_name(cls, name):
+ """Get an object using fields in the name matching ._regex or None"""
+ re_match = cls._regex.match(name)
+ return cls.from_fields(re_match.groupdict()) if re_match else None
+
+
[email protected]
+class RegistryName(ImageParserMixin):
+ """Parsed container registry name with no image"""
+ registry: str = None
+ domain: str = None
+ ipv6: str = None
+ port: str = None
+ _regex = re.compile(rf'^(?P<registry>{REGISTRY_REGEX})$')
+
+
[email protected]
+class ImageName(ImageParserMixin):
+ """Parsed container image name with no registry"""
+ image: str = None
+ path: str = None
+ tag: str = None
+ _regex = re.compile(rf'^(?P<image>{IMAGE_REGEX})$')
+
+
[email protected]
+class FullImageName(ImageParserMixin):
+ """Parsed image name including an optional registry"""
+ image: ImageName
+ registry: RegistryName = None
+ _regex = re.compile(
+ rf'^((?P<registry>{REGISTRY_REGEX})\/)?(?P<image>{IMAGE_REGEX})?$'
+ )
+
+ @property
+ def name(self):
+ """Full name in registry/image:tag format with optional registry"""
+ return (
+ '/'.join((self.registry.registry, self.image.image))
+ if self.registry else self.image.image
+ )
Sashiko wrote:
> Could this result in an AttributeError if self.image is None when
> accessing self.image.image?
In theory that's true, but the whole point is to initialise the class
using the regex parsing mechanism and when doing this, the field
would never be None. If it failed to parse the FullImageName object
wouldn't be constructed at all when using .from_name(). So an
exception if .image was None would seem justified - or even an
assert.
+ @classmethod
+ def from_name(cls, name):
+ if re_match := cls._regex.match(name):
+ fields = re_match.groupdict()
+ return FullImageName(
+ registry=RegistryName.from_fields(fields),
+ image=ImageName.from_fields(fields),
+ )
+ return None
Sashiko wrote:
> If the user provides an image name with a trailing slash like
> registry.com/, does the optional image capture group match nothing?
Ah yes, but that's a small bug in the regex due to the trailing '?'
which makes the image optional when it shouldn't. I'll fix this in a
v2 - and I guess it might explain the previous comment as if the
image part is not optional then .image should never be None.
+
class ContainerRuntime(abc.ABC):
"""Base class for a container runtime implementation"""
@@ -145,6 +229,7 @@ class Config:
"""Container configuration"""
image: str = None
runtime: str = None
+ registry: str = None
env_file: str = None
uid: int = None
gid: int = None
@@ -188,23 +273,40 @@ def _get_logger(verbose):
return logger
+def _get_image(args, config):
+ image_name = args.image or config.image
+ if not image_name:
+ raise ValueError("no image specified")
+ image = FullImageName.from_name(image_name)
+ if image is None:
+ raise ValueError(f"invalid image name: {image_name}")
+ if args.registry == '':
+ image.registry = None
+ elif args.registry is not None:
+ image.registry = RegistryName.from_name(args.registry)
+ if image.registry is None:
+ raise ValueError(f"invalid registry name: {args.registry}")
+ elif config.registry is not None and image.registry is None:
+ image.registry = RegistryName.from_name(config.registry)
+ if image.registry is None:
+ raise ValueError(f"invalid registry name: {config.registry}")
+ return image
+
+
def main(args):
"""Main entry point for the container tool"""
logger = _get_logger(args.verbose)
try:
config = Config.from_toml(args.config_file, args.config_profile)
- runtime = args.runtime or config.runtime
- cls = Runtimes.get(runtime) if runtime else Runtimes.find()
+ runtime_name = args.runtime or config.runtime
+ cls = Runtimes.get(runtime_name) if runtime_name else Runtimes.find()
+ logger.debug("runtime: %s", cls.name)
+ image = _get_image(args, config)
+ logger.debug("image: %s", image.name)
except ValueError as ex:
logger.error(ex)
return 1
- logger.debug("runtime: %s", cls.name)
- image = args.image or config.image
- if not image:
- logger.error("no image specified")
- return 1
- logger.debug("image: %s", image)
- return cls(args, config, logger).run(image, args.cmd)
+ return cls(args, config, logger).run(image.name, args.cmd)
if __name__ == '__main__':
@@ -235,6 +337,12 @@ if __name__ == '__main__':
help="Profile section to use in the config file. This will override "
"any values defined in the DEFAULT section."
)
+ parser.add_argument(
+ '-R', '--registry', nargs='?', const='',
+ help="Container registry name. Any registry already included in the "
+ "image name will be replaced or removed when using this option with "
+ "no value."
+ )
parser.add_argument(
'-r', '--runtime', choices=Runtimes.get_names(),
help="Container runtime name. If not specified, the first one found "
The rest of this patch appears to be Shashiko-friendly, at least in
this iteration. I'm updating the unit tests accordingly and will
send them in an RFC alongside the v2.
Guillaume