Skip to content

Commit 831b4e0

Browse files
authored
Merge pull request #49 from epics-containers/dev
Add ioc validate command for beamline CI
2 parents 08cdd87 + ecc1c92 commit 831b4e0

File tree

15 files changed

+208
-26
lines changed

15 files changed

+208
-26
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.4.0
3+
rev: v4.5.0
44
hooks:
55
- id: check-added-large-files
66
- id: check-yaml

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ classifiers = [
1313
"Programming Language :: Python :: 3.11",
1414
]
1515
description = "One line description of your module"
16-
dependencies = ["typer[all]", "ruamel.yaml", "jinja2"]
16+
dependencies = ["typer[all]", "requests", "ruamel.yaml", "jinja2"]
1717

1818
# Add project dependencies here, e.g. ["click", "numpy"]
1919
dynamic = ["version"]
@@ -27,7 +27,7 @@ dev = [
2727
"mock",
2828
"mypy",
2929
"pipdeptree",
30-
"pre-commit",
30+
"pre-commit==3.5.0",
3131
"pydata-sphinx-theme>=0.12",
3232
"pytest",
3333
"pytest-cov",

src/epics_containers_cli/dev/dev_cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,10 @@ def build(
213213
cache_from: Optional[str] = typer.Option(None, help="buildx cache from folder"),
214214
push: bool = typer.Option(False, help="buildx push to registry"),
215215
rebuild: bool = typer.Option(True, help="rebuild the image even if it exists"),
216+
target: Optional[str] = typer.Option(
217+
None, help="target to build (default: developer and runtime)"
218+
),
219+
suffix: Optional[str] = typer.Option(None, help="suffix for image"),
216220
):
217221
"""
218222
Build a generic IOC container locally from a container project.
@@ -229,4 +233,6 @@ def build(
229233
cache_to=cache_to,
230234
push=push,
231235
rebuild=rebuild,
236+
target=target,
237+
suffix=suffix,
232238
)

src/epics_containers_cli/dev/dev_commands.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,15 +190,21 @@ def build(
190190
cache_to: Optional[str],
191191
push: bool,
192192
rebuild: bool,
193+
target: Optional[str],
194+
suffix: Optional[str],
193195
):
194196
"""
195197
build a local image from a Dockerfile
196198
"""
197199
repo, _ = get_git_name(generic_ioc)
198200
args = f"--platform {platform} {'--no-cache' if not cache else ''}"
199201

200-
for target in Targets:
201-
image = get_image_name(repo, arch, target)
202+
if target is None:
203+
targets = [Targets.developer.value, Targets.runtime.value]
204+
else:
205+
targets = [target]
206+
for target in targets:
207+
image = get_image_name(repo, arch, target, suffix)
202208
image_name = f"{image}:{tag}"
203209

204210
if not rebuild:

src/epics_containers_cli/docker.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ class Docker:
2727
which CLI is being used and whether buildx is available.
2828
"""
2929

30-
def __init__(self, devcontainer: bool = False):
30+
def __init__(self, devcontainer: bool = False, check: bool = True):
3131
self.devcontainer = devcontainer
3232
self.docker: str = "podman"
3333
self.is_docker: bool = False
3434
self.is_buildx: bool = False
35-
self._check_docker()
35+
if check:
36+
self._check_docker()
3637

3738
def _check_docker(self):
3839
"""

src/epics_containers_cli/git.py

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
import re
77
from pathlib import Path
8-
from typing import Tuple
8+
from typing import Optional, Tuple
99

1010
import typer
1111

@@ -19,11 +19,17 @@
1919

2020

2121
def get_image_name(
22-
repo: str, arch: Architecture = Architecture.linux, target: str = "developer"
22+
repo: str,
23+
arch: Architecture = Architecture.linux,
24+
target: str = "developer",
25+
suffix: Optional[str] = None,
2326
) -> str:
27+
if suffix is None:
28+
suffix = "-{arch}-{target}"
2429
registry = repo2registry(repo).lower().removesuffix(".git")
30+
img_suffix = suffix.format(repo=repo, arch=arch, target=target, registry=registry)
2531

26-
image = f"{registry}-{arch}-{target}"
32+
image = f"{registry}{img_suffix}"
2733
log.info("repo = %s image = %s", repo, image)
2834
return image
2935

@@ -55,6 +61,24 @@ def get_git_name(folder: Path = Path(".")) -> Tuple[str, Path]:
5561
def repo2registry(repo_name: str) -> str:
5662
"""convert a repo name to the related a container registry name"""
5763

64+
# First try matching using the regex mappings environment variable
65+
registry = ""
66+
67+
for mapping in glob_vars.EC_REGISTRY_MAPPING_REGEX.split("\n"):
68+
if mapping == "":
69+
continue
70+
regex, replacement = mapping.split(" ")
71+
log.debug("regex = %s replacement = %s", regex, replacement)
72+
match = re.match(regex, repo_name)
73+
if match is not None:
74+
registry = match.expand(replacement)
75+
break
76+
77+
if registry:
78+
return registry
79+
80+
# Now try matching using the simple mappings environment variable.
81+
# Here automatically add the organization name to the image root URL
5882
log.debug("extracting fields from repo name %s", repo_name)
5983

6084
match_git = re.match(r"git@([^:]*):(.*)\/(.*)(?:.git)", repo_name)
@@ -69,10 +93,6 @@ def repo2registry(repo_name: str) -> str:
6993

7094
log.debug("source_reg = %s org = %s repo = %s", source_reg, org, repo)
7195

72-
if not glob_vars.EC_REGISTRY_MAPPING:
73-
log.error("environment variable EC_REGISTRY_MAPPING not set")
74-
raise typer.Exit(1)
75-
7696
for mapping in glob_vars.EC_REGISTRY_MAPPING.split():
7797
if mapping.split("=")[0] == source_reg:
7898
registry = mapping.split("=")[1]

src/epics_containers_cli/globals.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,39 @@ def __str__(self):
3333
# common stings used in paths
3434
BEAMLINE_CHART_FOLDER = "beamline-chart"
3535
CONFIG_FOLDER = "config"
36+
CONFIG_FILE = "ioc.yaml"
3637
IOC_CONFIG_FOLDER = "/epics/ioc/config/"
3738
IOC_START = "/epics/ioc/start.sh"
3839
IOC_NAME = "test-ioc"
3940
# these should be set to 0 or 1 in the environment - blank is treated as false
40-
EC_DEBUG = bool(os.environ.get("EC_DEBUG"))
41-
EC_VERBOSE = bool(os.environ.get("EC_VERBOSE"))
41+
EC_DEBUG = bool(os.environ.get("EC_DEBUG", 0))
42+
EC_VERBOSE = bool(os.environ.get("EC_VERBOSE", 0))
4243

44+
"""
45+
each mapping is a string of the form <source registry>=<container registry>
46+
the container registry is used to build the image name as
47+
<container registry>/<source organisation>/<repo name (without .git)>:<tag>
48+
mappings are separated by space or line break.
49+
50+
For a much more flexible way to define mappings see EC_REGISTRY_MAPPING_REGEX
51+
"""
4352
EC_REGISTRY_MAPPING = os.environ.get(
4453
"EC_REGISTRY_MAPPING",
45-
"github.com=ghcr.io gitlab.diamond.ac.uk=gcr.io/diamond-privreg/controls/ioc",
54+
"github.com=ghcr.io",
4655
)
56+
57+
"""
58+
each mapping is a regex to match the repo name and a replacement string
59+
to generate the container registry name. Mappings are separated by line
60+
break and regex and replacement are separated by space
61+
"""
62+
EC_REGISTRY_MAPPING_REGEX = os.environ.get(
63+
"EC_REGISTRY_MAPPING",
64+
r"""
65+
.*github.com:(.*)\/(.*)\.git ghcr.io/\1/\2
66+
.*gitlab.diamond.ac.uk.*\/(.*).git gcr.io/diamond-privreg/controls/prod/ioc/\1
67+
""",
68+
)
69+
4770
EC_CONTAINER_CLI = os.environ.get("EC_CONTAINER_CLI") # default to auto choice
4871
EC_LOG_URL = os.environ.get("EC_LOG_URL", None)

src/epics_containers_cli/ioc/ioc_cli.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,24 @@ def stop(
184184
IocLocalCommands(ctx.obj, ioc_name).stop()
185185
else:
186186
IocK8sCommands(ctx.obj, ioc_name).stop()
187+
188+
189+
@ioc.command()
190+
def validate(
191+
ctx: typer.Context,
192+
ioc_instance: Path = typer.Argument(
193+
...,
194+
help="folder of local ioc definition",
195+
exists=True,
196+
file_okay=False,
197+
resolve_path=True,
198+
),
199+
):
200+
"""
201+
Verify a local IOC definition folder is valid
202+
203+
Checks that values.yaml points at a valid image
204+
Checks that ioc.yaml has the matching schema header and that it passes
205+
scheme validation
206+
"""
207+
IocLocalCommands(ctx.obj).validate_instance(ioc_instance)

src/epics_containers_cli/ioc/local_commands.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,41 @@
88
'ec deploy <ioc_name> <ioc_version> and then managing the network with a
99
tool like Portainer is a decent workflow.
1010
"""
11-
11+
import re
1212
import shutil
1313
from datetime import datetime
1414
from pathlib import Path
1515
from tempfile import mkdtemp
1616
from typing import Optional
1717

18+
import requests
1819
import typer
1920

2021
import epics_containers_cli.globals as glob_vars
2122
from epics_containers_cli.docker import Docker
22-
from epics_containers_cli.globals import CONFIG_FOLDER, IOC_CONFIG_FOLDER, Context
23+
from epics_containers_cli.globals import (
24+
CONFIG_FILE,
25+
CONFIG_FOLDER,
26+
IOC_CONFIG_FOLDER,
27+
Context,
28+
)
2329
from epics_containers_cli.logging import log
2430
from epics_containers_cli.shell import check_beamline_repo, run_command
25-
from epics_containers_cli.utils import check_ioc_instance_path, get_instance_image_name
31+
from epics_containers_cli.utils import (
32+
check_ioc_instance_path,
33+
generic_ioc_from_image,
34+
get_instance_image_name,
35+
)
2636

2737

2838
class IocLocalCommands:
2939
"""
3040
A class for implementing the ioc command namespace for local docker/podman
3141
"""
3242

33-
def __init__(self, ctx: Optional[Context], ioc_name: str = ""):
43+
def __init__(
44+
self, ctx: Optional[Context], ioc_name: str = "", with_docker: bool = True
45+
):
3446
self.beamline_repo: str = ""
3547
if ctx is not None:
3648
self.beamline_repo = ctx.beamline_repo
@@ -39,7 +51,7 @@ def __init__(self, ctx: Optional[Context], ioc_name: str = ""):
3951

4052
self.tmp = Path(mkdtemp())
4153
self.ioc_folder = self.tmp / "iocs" / ioc_name
42-
self.docker = Docker()
54+
self.docker = Docker(check=with_docker)
4355

4456
def __del__(self):
4557
# keep the tmp folder if debug is enabled for inspection_del
@@ -160,3 +172,56 @@ def ps(self, all: bool, wide: bool):
160172

161173
for row in rows:
162174
print("{0: <20.20} {1: <20.20} {2: <23.23} {3}".format(*row))
175+
176+
def validate_instance(self, ioc_instance: Path):
177+
check_ioc_instance_path(ioc_instance)
178+
179+
typer.echo(f"Validating {ioc_instance}")
180+
181+
ioc_config_file = ioc_instance / CONFIG_FOLDER / CONFIG_FILE
182+
image = get_instance_image_name(ioc_instance)
183+
image_name, image_tag = image.split(":")
184+
185+
tmp = Path(mkdtemp())
186+
schema_file = tmp / "schema.json"
187+
188+
# not all IOCs have a config file so no config validation for them
189+
if ioc_config_file.exists():
190+
config = ioc_config_file.read_text()
191+
matches = re.findall(r"#.* \$schema=(.*)", config)
192+
if not matches:
193+
raise RuntimeError("No schema modeline found in {ioc_config_file}")
194+
195+
schema_url = matches[0]
196+
197+
with requests.get(schema_url, allow_redirects=True) as r:
198+
schema_file.write_text(r.content.decode())
199+
200+
if not run_command("yajsv -v", interactive=False, error_OK=True):
201+
typer.echo(
202+
"yajsv, used for schema validation of ioc.yaml, is not installed. "
203+
"Please install from https://github.com/neilpa/yajsv"
204+
)
205+
raise typer.Exit(1)
206+
207+
run_command(f"yajsv -s {schema_file} {ioc_config_file}", interactive=False)
208+
209+
# check that the image name and the schema are from the same generic IOC
210+
if image_tag not in schema_url:
211+
log.error(f"image version {image_tag} and {schema_url} do not match")
212+
raise typer.Exit(1)
213+
214+
# make sure that generic IOC name matches the schema
215+
generic_ioc = generic_ioc_from_image(image_name)
216+
if generic_ioc not in schema_url:
217+
log.error(
218+
f"ioc.yaml schema {schema_url} does not match generic IOC {generic_ioc}"
219+
)
220+
raise typer.Exit(1)
221+
222+
# verify that the values.yaml file points to a container image that exists
223+
run_command(f"{self.docker.docker} image inspect {image}", interactive=False)
224+
225+
shutil.rmtree(tmp, ignore_errors=True)
226+
227+
typer.echo(f"{ioc_instance} validated successfully")

src/epics_containers_cli/utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ def get_instance_image_name(ioc_instance: Path, tag: Optional[str] = None) -> st
3232
return image
3333

3434

35-
def check_ioc_instance_path(ioc_path: Path, yes: bool = False):
35+
def check_ioc_instance_path(ioc_path: Path):
3636
"""
3737
verify that the ioc instance path is valid
3838
"""
@@ -52,3 +52,15 @@ def check_ioc_instance_path(ioc_path: Path, yes: bool = False):
5252
raise typer.Exit(1)
5353

5454
return ioc_name, ioc_path
55+
56+
57+
def generic_ioc_from_image(image_name: str) -> str:
58+
"""
59+
return the generic IOC name from an image name
60+
"""
61+
match = re.findall(r".*\/(.*)-.*-(?:runtime|developer)", image_name)
62+
if not match:
63+
log.error(f"cannot extract generic IOC name from {image_name}")
64+
raise typer.Exit(1)
65+
66+
return match[0]

0 commit comments

Comments
 (0)