chore: fix templating
Deploy bin / deploy (push) Canceled after 0s
Deploy service MASTER / deploy (push) Canceled after 0s

This commit is contained in:
Benjamin Labrecque
2026-07-03 15:02:08 +02:00
parent 4699b0f606
commit 09edcf53a2
34 changed files with 123 additions and 426 deletions
+44
View File
@@ -0,0 +1,44 @@
import logging
from pathlib import Path
import git
logger = logging.getLogger()
def git_push_service_directory(
repo_root: str | Path, branch_name: str, service_dir_rel_path: str, commit_msg: str
):
"""
Push newly created service to Gitea
"""
try:
repo = git.Repo(repo_root)
logger.info(f"Switching to branch '{branch_name}'...")
# Create branch if it doesn't exist, then checkout
if branch_name in repo.heads:
new_branch = repo.heads[branch_name]
else:
new_branch = repo.create_head(branch_name)
new_branch.checkout()
logger.info(f"Staging files inside: {service_dir_rel_path}...")
repo.index.add([service_dir_rel_path])
# Check if there are changes to avoid empty commit crashes
if not repo.is_dirty(index=True, working_tree=False, path=service_dir_rel_path):
logger.info("No changes detected. Skipping commit.")
return
logger.info("Committing changes...")
repo.index.commit(commit_msg)
logger.info(f"Pushing branch '{branch_name}' to origin...")
origin = repo.remote(name="origin")
origin.push(refspec=f"{branch_name}:{branch_name}").raise_if_error()
logger.info("Successfully pushed!")
except Exception as e:
logger.info(f"Git Operation Failed: {e}")
+31 -6
View File
@@ -1,8 +1,9 @@
import re
from pathlib import Path
from typing import ClassVar
import yaml
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
from core.enums import ServiceStatus
@@ -16,12 +17,36 @@ class Service(BaseModel):
name: str
status: ServiceStatus
@property
def name_upper(self):
return self.name.upper()
@property
def name_lower(self):
return self.name.lower()
@property
def dir_name(self):
return f"{self.id_str}-{self.name_lower}"
@property
def id_str(self):
# TODO: max 999 services, enough?
return f"{self.id:03d}"
def to_dict(self):
return {"name": self.name, "status": str(self.status)}
return {"name": self.name_lower, "status": str(self.status)}
class Config:
use_enum_values = True
@field_validator("name")
@classmethod
def validate_name_chars(cls, value: str) -> str:
if not re.match(r"^[a-zA-Z0-9-]+$", value):
raise ValueError("name can only contain letters, numbers, and dashes")
return value
class ServiceRegistry(BaseModel):
config_path: ClassVar[str] = str(SERVICE_REGISTRY_FILENAME)
@@ -40,11 +65,11 @@ class ServiceRegistry(BaseModel):
def get_services(self) -> list[Service]:
return [Service(id=svc_id, **svc_data) for svc_id, svc_data in self.services.items()]
def add_service(self, name: str) -> int:
s = Service(id=self.next_available_id, name=name, status=ServiceStatus.ACTIVE)
self.services.update({s.id: s.to_dict()})
def add_service(self, name: str) -> Service:
svc = Service(id=self.next_available_id, name=name, status=ServiceStatus.ACTIVE)
self.services.update({svc.id: svc.to_dict()})
self.next_available_id += 1
with open(self.config_path, "w") as f:
yaml.safe_dump(self.model_dump(), f)
return self.next_available_id
return svc
+5 -5
View File
@@ -8,8 +8,9 @@ SERVICE_DEST_DIR = REPO_ROOT / "services"
def render_new_service_templates(
service_id: int,
service_name: str,
service_id: str,
service_name_upper: str,
service_name_lower: str,
ioc_host: str,
ioc_port: int,
user: str,
@@ -22,9 +23,8 @@ def render_new_service_templates(
dst_path=str(SERVICE_DEST_DIR),
data={
"service_id": service_id,
"service_name": service_name,
# TODO: make sure this is ok where used (need to replace some chars by '-'?)
"service_name_lower": service_name.lower(),
"service_name_upper": service_name_upper,
"service_name_lower": service_name_lower,
# TODO: restrict this, use enum
"ioc_host": ioc_host,
"ioc_port": ioc_port,
+8
View File
@@ -1,3 +1,4 @@
import logging
from pathlib import Path
import git
@@ -8,3 +9,10 @@ def get_git_root(path) -> Path:
git_root = git_repo.git.rev_parse("--show-toplevel")
return Path(git_root)
def init_logging(log_level: str = "info"):
"""
Configure logging -- set log level
"""
logging.basicConfig(level=getattr(logging, log_level.upper()))
+18 -6
View File
@@ -1,24 +1,36 @@
import typer
from core.git import git_push_service_directory
from core.models import ServiceRegistry
from core.render_templates import render_new_service_templates
from core.utils import get_git_root, init_logging
service = typer.Typer(no_args_is_help=True)
REPO_ROOT = get_git_root(__file__)
@service.command()
def add(
name: str = typer.Option(..., "--name", "-n"),
):
init_logging()
# TODO: add restriction on name (maybe: [a-zA-Z0-9-])
registry = ServiceRegistry.read_from_config()
service_id = registry.add_service(name)
service = registry.add_service(name)
render_new_service_templates(
service_id=service_id,
service_name=name,
ioc_host="",
ioc_port=9999,
user="",
service_id=service.id_str,
service_name_upper=service.name_upper,
service_name_lower=service.name_lower,
ioc_host="ex-host", # TODO
ioc_port=9999, # TODO
user="user", # TODO
)
git_push_service_directory(
repo_root=REPO_ROOT,
branch_name=f"feature/add-service-{service.name_lower}",
service_dir_rel_path=f"services/{service.dir_name}",
commit_msg=f"feature: add new service {service.name_lower}",
)
+1 -4
View File
@@ -1,4 +1,4 @@
next_available_id: 6
next_available_id: 5
services:
0:
name: master
@@ -15,6 +15,3 @@ services:
4:
name: cool-service
status: active
5:
name: cool-service
status: active
@@ -1,33 +0,0 @@
[project]
name = "agebd-cool-service"
version = "0.1.0"
description = "AGEBD-COOL-SERVICE Service"
requires-python = ">=3.9"
dependencies = [
"agebd==1.2.0",
]
[dependency-groups]
dev = [
"pytest>=8.4.2",
"ruff>=0.15.20",
]
# This tells uv where to look for 'agebd' during LOCAL dev work
[tool.uv.sources]
# Path is relative to where agebd package is deployed on hla
# machines (see CI/CD in .gitea/workflows)
agebd = { path = "../../../../../packages/{{ agebd_env }}/agebd", editable = true }
[tool.ruff]
include = [
"src/**/*.py",
]
line-length = 100
[tool.ruff.lint]
# use isort to sort imports
extend-select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["agebd"]
@@ -1,108 +0,0 @@
from time import sleep
import typer
from epics import dbr
from agebd.enums import LogLevel
from agebd.pv import LocalPVLink
from agebd.pv import PVLink as PV
from agebd.runner import CallbackRunner
from agebd.service.base import BaseService
from agebd.service.pvs import BasePVs
from agebd.utils import get_pv_class, init_logging, printgetversion
__version__ = printgetversion(__file__)
PV = get_pv_class()
class PVs(BasePVs):
# Option 1:
## Service specific PVs from dedicated IOC
my_pv1 = PV("MY_PV1")
## Service specific PVs from other IOCs
# ...
def __init__(self, service_name: str, pv_factory):
super().__init__(service_name, pv_factory)
PV = pv_factory
# TODO: can we do this cleaner? Maybe mechanism for
# settings initial values in dev mode (e.g. ini file)
if isinstance(self.onoff, LocalPVLink):
self.onoff.value = True
# Option 2:
## Service specific PVs from dedicated IOC
self.my_pv2 = PV("MY_PV2")
## Service specific PVs from other IOCs
# ...
# TODO: class attribute names usually lowercase
# usually it is advisable to trigger the mainloop of a service on changes of certain PVs:
self.CallbackPV = PV("...", auto_monitor=dbr.DBE_VALUE)
class Service(BaseService[PVs]):
def __init__(
self, name: str, pvs: PVs, version: str = __version__, sleep_interval: float = 0.1
):
super().__init__(name, pvs, version, sleep_interval)
# TODO: What is this for?
self.path = "sls/bd/bin"
def update(self):
# when callback is fired, this code will be executed
if self.CallbackFired:
#######################################################################################
#######################################################################################
# here goes all the code that your service
# is supposed to be running when the callback is fired
sleep(1)
#######################################################################################
#######################################################################################
# callback done
self.CallbackFired = 0
#######################################################################################
#######################################################################################
# here goes all the code that your service
# is supposed to be running in any case, e.g.,
# update some pvs/vals
self.pvs.my_pv1.put("MY_PV1 value")
self.pvs.my_pv2.put("MY_PV2 value")
self.val1 = self.pvs.my_pv1.get()
self.val2 = self.pvs.my_pv2.get()
print(self.val1)
print(self.val2)
# maybe sleep to limit the update loop execution rate
sleep(1)
#######################################################################################
#######################################################################################
def main(
log_level: LogLevel = typer.Option(
LogLevel.INFO,
"--log-level",
"-l",
# Looks at the OS env variable first. If empty, falls back to "INFO"
envvar="SLS_SERVICE_LOG_LEVEL",
# help="Set log level: DEBUG, INFO, WARNING, ERROR, CRITICAL",
case_sensitive=False,
),
):
init_logging(log_level)
service_name = ""
pvs = PVs(service_name=service_name, pv_factory=PV)
service = Service(name=service_name, pvs=pvs)
runner = CallbackRunner(service=service)
runner.start()
if __name__ == "__main__":
typer.run(main)
@@ -1,4 +0,0 @@
file COOL-SERVICE.template {
pattern { DEVICE }
{ AGEBD-COOL-SERVICE }
}
@@ -1,6 +0,0 @@
cpu_architecture: x86_64
epics_version: 7.0.8
ioc_host:
ioc_port: 9999
os: RHEL8
os_id: rhel
@@ -1 +0,0 @@
epicsEnvSet("ENGINEER", "")
@@ -1,16 +0,0 @@
[Unit]
Description=AGEBD-SERVICE-COOL-SERVICE
After=network.target
[Service]
Environment=AGEBD_ENV={{ agebd_env }}
ExecStart=/sls/bd/hla/bin/start_service.sh 004 COOL-SERVICE
estart=no
[Install]
WantedBy=default.target
# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
# systemctl --user daemon-reload
# systemctl --user enable /sls/bd/bin/systemd/AGEBD-SERVICE-COOL-SERVICE.service
# systemctl --user start AGEBD-SERVICE-COOL-SERVICE
@@ -1,33 +0,0 @@
[project]
name = "agebd-cool-service"
version = "0.1.0"
description = "AGEBD-cool-service Service"
requires-python = ">=3.9"
dependencies = [
"agebd==1.2.0",
]
[dependency-groups]
dev = [
"pytest>=8.4.2",
"ruff>=0.15.20",
]
# This tells uv where to look for 'agebd' during LOCAL dev work
[tool.uv.sources]
# Path is relative to where agebd package is deployed on hla
# machines (see CI/CD in .gitea/workflows)
agebd = { path = "../../../../../packages/{{ agebd_env }}/agebd", editable = true }
[tool.ruff]
include = [
"src/**/*.py",
]
line-length = 100
[tool.ruff.lint]
# use isort to sort imports
extend-select = ["I"]
[tool.ruff.lint.isort]
known-first-party = ["agebd"]
@@ -1,4 +0,0 @@
file cool-service.template {
pattern { DEVICE }
{ AGEBD-cool-service }
}
@@ -1,6 +0,0 @@
cpu_architecture: x86_64
epics_version: 7.0.8
ioc_host:
ioc_port: 9999
os: RHEL8
os_id: rhel
@@ -1,27 +0,0 @@
# This is a template for preaparing an IOC providing BD Service specific PVs
# Access Security Groups:
# DEFAULT - logt wenn EGU oder so veraendert werden
# LOG - logt wenn neuer wert geschrieben wird
# PROTECTED - verhindert versuchte caputs auf alle fields ausser VAL
# READONLY - verhindert versuchte caputs und aktiviert caputlog
# Input links can specify CA, CP, or CPP.
# If the input link specifies CA, it will be forced to be a Channel Access link.
# If the input link specifies CP, it will also be forced to be a Channel Access link; in addition, it will cause the record containing the input link to process whenever a monitor is posted, no matter what the record's SCAN field specifies.
# If the input link specifies CPP, it means the same thing as CP, except that the record will be processed if and only if the record itself specifies passive in its SCAN field. Output links can specify CA, which will simply cause them to be Channel Access links.
#
record(ao, "$(DEVICE):AO") {
field(DESC, "Analog Output Record (ao)")
field(ASG, "PROTECTED")
field(VAL, "0")
field(PINI, "YES")
}
record(bo, "$(DEVICE):BO") {
field(DESC, "Binary Output Record (bo)")
field(ASG, "READONLY")
field(ZSV, "MINOR")
field(OSV, "NO_ALARM")
field(VAL, "1")
field(PINI, "YES")
}
@@ -1 +0,0 @@
epicsEnvSet("ENGINEER", "")
@@ -1 +0,0 @@
# TODO
@@ -1,16 +0,0 @@
[Unit]
Description=AGEBD-SERVICE-cool-service
After=network.target
[Service]
Environment=AGEBD_ENV={{ agebd_env }}
ExecStart=/sls/bd/hla/bin/start_service.sh 1 cool-service
estart=no
[Install]
WantedBy=default.target
# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
# systemctl --user daemon-reload
# systemctl --user enable /sls/bd/bin/systemd/AGEBD-SERVICE-cool-service.service
# systemctl --user start AGEBD-SERVICE-cool-service
@@ -1,7 +1,7 @@
[project]
name = "agebd-{{ service_name_lower }}"
version = "0.1.0"
description = "AGEBD-{{ service_name }} Service"
description = "AGEBD-{{ service_name_upper }} Service"
requires-python = ">=3.9"
dependencies = [
"agebd==1.2.0",
@@ -1,8 +1,6 @@
from time import sleep
import typer
from epics import dbr
from agebd.enums import LogLevel
from agebd.pv import LocalPVLink
from agebd.pv import PVLink as PV
@@ -10,6 +8,7 @@ from agebd.runner import CallbackRunner
from agebd.service.base import BaseService
from agebd.service.pvs import BasePVs
from agebd.utils import get_pv_class, init_logging, printgetversion
from epics import dbr
__version__ = printgetversion(__file__)
@@ -47,7 +46,11 @@ class PVs(BasePVs):
class Service(BaseService[PVs]):
def __init__(
self, name: str, pvs: PVs, version: str = __version__, sleep_interval: float = 0.1
self,
name: str,
pvs: PVs,
version: str = __version__,
sleep_interval: float = 0.1,
):
super().__init__(name, pvs, version, sleep_interval)
@@ -97,7 +100,7 @@ def main(
),
):
init_logging(log_level)
service_name = "cool-service"
service_name = "{{ service_name_upper }}"
pvs = PVs(service_name=service_name, pv_factory=PV)
service = Service(name=service_name, pvs=pvs)
runner = CallbackRunner(service=service)
@@ -0,0 +1,4 @@
file {{ service_name_upper }}.template {
pattern { DEVICE }
{ AGEBD-{{ service_name_upper }} }
}
@@ -1,10 +1,10 @@
[Unit]
Description=AGEBD-SERVICE-{{service_name}}
Description=AGEBD-SERVICE-{{ service_name_upper }}
After=network.target
[Service]
Environment=AGEBD_ENV={{ '{{ agebd_env }}' }}
ExecStart=/sls/bd/hla/bin/start_service.sh {{ service_id }} {{ service_name }}
ExecStart=/sls/bd/hla/bin/start_service.sh {{ service_id }} {{ service_name_upper }}
estart=no
[Install]
@@ -12,5 +12,5 @@ WantedBy=default.target
# https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html
# systemctl --user daemon-reload
# systemctl --user enable /sls/bd/bin/systemd/AGEBD-SERVICE-{{ service_name }}.service
# systemctl --user start AGEBD-SERVICE-{{ service_name }}
# systemctl --user enable /sls/bd/bin/systemd/AGEBD-SERVICE-{{ service_name_upper }}.service
# systemctl --user start AGEBD-SERVICE-{{ service_name_upper }}
@@ -1,108 +0,0 @@
from time import sleep
import typer
from epics import dbr
from agebd.enums import LogLevel
from agebd.pv import LocalPVLink
from agebd.pv import PVLink as PV
from agebd.runner import CallbackRunner
from agebd.service.base import BaseService
from agebd.service.pvs import BasePVs
from agebd.utils import get_pv_class, init_logging, printgetversion
__version__ = printgetversion(__file__)
PV = get_pv_class()
class PVs(BasePVs):
# Option 1:
## Service specific PVs from dedicated IOC
my_pv1 = PV("MY_PV1")
## Service specific PVs from other IOCs
# ...
def __init__(self, service_name: str, pv_factory):
super().__init__(service_name, pv_factory)
PV = pv_factory
# TODO: can we do this cleaner? Maybe mechanism for
# settings initial values in dev mode (e.g. ini file)
if isinstance(self.onoff, LocalPVLink):
self.onoff.value = True
# Option 2:
## Service specific PVs from dedicated IOC
self.my_pv2 = PV("MY_PV2")
## Service specific PVs from other IOCs
# ...
# TODO: class attribute names usually lowercase
# usually it is advisable to trigger the mainloop of a service on changes of certain PVs:
self.CallbackPV = PV("...", auto_monitor=dbr.DBE_VALUE)
class Service(BaseService[PVs]):
def __init__(
self, name: str, pvs: PVs, version: str = __version__, sleep_interval: float = 0.1
):
super().__init__(name, pvs, version, sleep_interval)
# TODO: What is this for?
self.path = "sls/bd/bin"
def update(self):
# when callback is fired, this code will be executed
if self.CallbackFired:
#######################################################################################
#######################################################################################
# here goes all the code that your service
# is supposed to be running when the callback is fired
sleep(1)
#######################################################################################
#######################################################################################
# callback done
self.CallbackFired = 0
#######################################################################################
#######################################################################################
# here goes all the code that your service
# is supposed to be running in any case, e.g.,
# update some pvs/vals
self.pvs.my_pv1.put("MY_PV1 value")
self.pvs.my_pv2.put("MY_PV2 value")
self.val1 = self.pvs.my_pv1.get()
self.val2 = self.pvs.my_pv2.get()
print(self.val1)
print(self.val2)
# maybe sleep to limit the update loop execution rate
sleep(1)
#######################################################################################
#######################################################################################
def main(
log_level: LogLevel = typer.Option(
LogLevel.INFO,
"--log-level",
"-l",
# Looks at the OS env variable first. If empty, falls back to "INFO"
envvar="SLS_SERVICE_LOG_LEVEL",
# help="Set log level: DEBUG, INFO, WARNING, ERROR, CRITICAL",
case_sensitive=False,
),
):
init_logging(log_level)
service_name = "{{service_name}}"
pvs = PVs(service_name=service_name, pv_factory=PV)
service = Service(name=service_name, pvs=pvs)
runner = CallbackRunner(service=service)
runner.start()
if __name__ == "__main__":
typer.run(main)
@@ -1,4 +0,0 @@
file {{ service_name }}.template {
pattern { DEVICE }
{ AGEBD-{{ service_name }} }
}
@@ -1,27 +0,0 @@
# This is a template for preaparing an IOC providing BD Service specific PVs
# Access Security Groups:
# DEFAULT - logt wenn EGU oder so veraendert werden
# LOG - logt wenn neuer wert geschrieben wird
# PROTECTED - verhindert versuchte caputs auf alle fields ausser VAL
# READONLY - verhindert versuchte caputs und aktiviert caputlog
# Input links can specify CA, CP, or CPP.
# If the input link specifies CA, it will be forced to be a Channel Access link.
# If the input link specifies CP, it will also be forced to be a Channel Access link; in addition, it will cause the record containing the input link to process whenever a monitor is posted, no matter what the record's SCAN field specifies.
# If the input link specifies CPP, it means the same thing as CP, except that the record will be processed if and only if the record itself specifies passive in its SCAN field. Output links can specify CA, which will simply cause them to be Channel Access links.
#
record(ao, "$(DEVICE):AO") {
field(DESC, "Analog Output Record (ao)")
field(ASG, "PROTECTED")
field(VAL, "0")
field(PINI, "YES")
}
record(bo, "$(DEVICE):BO") {
field(DESC, "Binary Output Record (bo)")
field(ASG, "READONLY")
field(ZSV, "MINOR")
field(OSV, "NO_ALARM")
field(VAL, "1")
field(PINI, "YES")
}