diff --git a/.gitea/scripts/deploy-and-restart-service.sh b/.gitea/scripts/deploy-and-restart-service.sh index 95834c3..c66556b 100755 --- a/.gitea/scripts/deploy-and-restart-service.sh +++ b/.gitea/scripts/deploy-and-restart-service.sh @@ -7,17 +7,22 @@ echo "==========================================" echo "Deploying service: $SERVICE_NAME (${AGEBD_ENV})" echo "==========================================" -SERVICE_NAME_LOWER=$(echo "$SERVICE_NAME" | tr '[:upper:]' '[:lower:]') -SERVICE_NAME_UPPER=$(echo "$SERVICE_NAME" | tr '[:lower:]' '[:upper:]') -SERVICE_DIR=/sls/bd/hla/${AGEBD_ENV}/services/${SERVICE_NAME_LOWER}/current -SYSTEMD_UNIT_NAME=AGEBD-SERVICE-${SERVICE_NAME_UPPER}.service +# Name forms and the systemd naming convention come from +# agebd.names.ServiceName, so this script carries no copy of them. +if ! SERVICE_NAMES=$(uv run --directory scripts ci/service_names.py "${SERVICE_NAME}"); then + echo "Could not derive names for '${SERVICE_NAME}' -- aborting deploy" >&2 + exit 1 +fi +eval "${SERVICE_NAMES}" + +SERVICE_DIR=/sls/bd/hla/${AGEBD_ENV}/services/${SERVICE_DIR_NAME}/current SYSTEMD_UNIT_FILE_PATH=${SERVICE_DIR}/systemd/${SYSTEMD_UNIT_NAME} IOC_DIR=${SERVICE_DIR}/ioc # TODO: see https://gitea.psi.ch/sls/hla_framework_bd/issues/50 # rm -rf "${SERVICE_DIR}" # mkdir -p ${SERVICE_DIR} -# cp -r ./services/${SERVICE_NAME_LOWER}/current/* ${SERVICE_DIR}/ +# cp -r ./services/${SERVICE_DIR_NAME}/current/* ${SERVICE_DIR}/ rsync -avz --delete \ --exclude='.nfs*' \ @@ -26,7 +31,7 @@ rsync -avz --delete \ --exclude='__pycache__/' \ --exclude='*.pyc' \ --exclude='.pytest_cache/' \ - ./services/${SERVICE_NAME_LOWER}/current/* ${SERVICE_DIR}/ + ./services/${SERVICE_DIR_NAME}/current/* ${SERVICE_DIR}/ ### SUBSTITUTIONS # Every @@token@@ is resolved from config/environments.yml and @@ -34,7 +39,7 @@ rsync -avz --delete \ # deploy instead of leaving the placeholder in a file the IOC will read. if ! uv run --directory scripts ci/render_subs.py \ --env "${AGEBD_ENV}" \ - --service "${SERVICE_NAME_LOWER}" \ + --service "${SERVICE_DIR_NAME}" \ "${IOC_DIR}" "${SERVICE_DIR}/systemd"; then echo "Substitution failed -- aborting deploy of ${SERVICE_NAME}" >&2 exit 1 diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg deleted file mode 100644 index 758081a..0000000 --- a/ansible/ansible.cfg +++ /dev/null @@ -1,8 +0,0 @@ -[defaults] -remote_tmp = /tmp/ansible-$USER - -# some responses of 'ioc ...' cli are non-utf8 -module_strict_utf8_response = False - -# ./ refers to dir in which this file is located -collections_path = ./installed-collections diff --git a/ansible/collections/requirements.yml b/ansible/collections/requirements.yml deleted file mode 100644 index 6b3fb4c..0000000 --- a/ansible/collections/requirements.yml +++ /dev/null @@ -1,3 +0,0 @@ -collections: - - name: ansible.posix - version: 1.6.0 \ No newline at end of file diff --git a/ansible/inventories/dev/hosts.yml b/ansible/inventories/dev/hosts.yml deleted file mode 100644 index fe426e5..0000000 --- a/ansible/inventories/dev/hosts.yml +++ /dev/null @@ -1,19 +0,0 @@ -all: - vars: - # Suffix values for this environment live in config/environments.yml -- - # do not reintroduce them here. - agebd_env: "dev" - - children: - lc: - hosts: - sls-lc: - ansible_remote_tmp: /tmp/.ansible_${ansible_user}_tmp - vserv: - hosts: - sls-vserv-bd-01-dev: - ansible_ssh_pipelining: true - ansible_ssh_common_args: '-o ForwardAgent=yes' - vserv-hla: - hosts: - sls-vserv-bd-hla01-dev: diff --git a/ansible/inventories/prod/hosts.yml b/ansible/inventories/prod/hosts.yml deleted file mode 100644 index ec25a8e..0000000 --- a/ansible/inventories/prod/hosts.yml +++ /dev/null @@ -1,18 +0,0 @@ -all: - vars: - # Suffix values for this environment live in config/environments.yml -- - # do not reintroduce them here. - agebd_env: "prod" - - children: - lc: - hosts: - sls-lca: - ansible_remote_tmp: /tmp/.ansible_${ansible_user}_tmp - vserv: - hosts: - sls-vserv-bd-01: - ansible_ssh_pipelining: true - vserv-hla: - hosts: - sls-vserv-bd-hla01: diff --git a/ansible/playbooks/add-new-service.yml b/ansible/playbooks/add-new-service.yml deleted file mode 100644 index a2bfbd0..0000000 --- a/ansible/playbooks/add-new-service.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- -- name: Add a new service - hosts: vserv-hla - connection: local - gather_facts: true # Gather once here, available to all included tasks! - - vars: - repo_root: "{{ playbook_dir }}/../.." - - tasks: - # - name: Install Master IOC - # vars: - # preferred_service_name_lower: master - # branch_name: "feature/add-service-{{ service_name_lower }}" - # import_tasks: ../tasks/ioc/ioc-install.yml - - # - name: Restart Master IOC - # vars: - # preferred_service_name_lower: master - # branch_name: "feature/add-service-{{ service_name_lower }}" - # import_tasks: ../tasks/ioc/ioc-restart.yml - - - name: Deploy and Restart Master Service - vars: - preferred_service_name_lower: master - branch_name: "feature/add-service-{{ service_name_lower }}" - import_tasks: ../tasks/service/service-deploy-and-restart.yml - - # - name: Install New IOC - # vars: - # branch_name: "feature/add-service-{{ service_name_lower }}" - # import_tasks: ../tasks/ioc/ioc-install.yml - - # - name: Start New IOC - # vars: - # branch_name: "feature/add-service-{{ service_name_lower }}" - # import_tasks: ../tasks/ioc/ioc-start.yml - - - name: Deploy and Restart New Service - vars: - branch_name: "feature/add-service-{{ service_name_lower }}" - import_tasks: ../tasks/service/service-deploy-and-restart.yml diff --git a/ansible/playbooks/deploy-and-restart-modified-services.yml b/ansible/playbooks/deploy-and-restart-modified-services.yml deleted file mode 100644 index cb47314..0000000 --- a/ansible/playbooks/deploy-and-restart-modified-services.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -- name: Deploy and Restart Modified Services - hosts: vserv-hla - connection: local - - vars: - # Passed from the Gitea workflow, e.g., "master playground" - changed_services_raw: "" - agebd_env: "dev" - repo_root: "{{ playbook_dir }}/../.." - - tasks: - # Fail early if no services were modified - - name: Check if there are services to deploy - ansible.builtin.meta: end_play - when: changed_services_raw | trim == "" - - # Main Loop: loop through the list of modified service names - - name: Deploy and Restart Modified Services - include_tasks: ../tasks/service/service-deploy-and-restart.yml - loop: "{{ changed_services_raw.split() }}" - loop_control: - loop_var: service_name_lower - extended: yes diff --git a/ansible/playbooks/ioc-install.yml b/ansible/playbooks/ioc-install.yml deleted file mode 100644 index c78d698..0000000 --- a/ansible/playbooks/ioc-install.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -- name: Install an IOC - hosts: lc - gather_facts: false - - vars: - workspace_dir: "/tmp/hla_framework_bd" - # Controller-side checkout, where config/environments.yml is read from - repo_root: "{{ playbook_dir }}/../.." - - tasks: - - name: Run ioc install tasks - ansible.builtin.import_tasks: ../tasks/ioc/ioc-install.yml diff --git a/ansible/playbooks/ioc-restart.yml b/ansible/playbooks/ioc-restart.yml deleted file mode 100644 index 7c54986..0000000 --- a/ansible/playbooks/ioc-restart.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -- name: Restart an IOC - hosts: lc - gather_facts: false - - vars: - workspace_dir: "/tmp/hla_framework_bd" - # Controller-side checkout, where config/environments.yml is read from - repo_root: "{{ playbook_dir }}/../.." - - tasks: - - name: Run ioc restart tasks - ansible.builtin.include_tasks: ../tasks/ioc/ioc-restart.yml diff --git a/ansible/playbooks/ioc-start.yml b/ansible/playbooks/ioc-start.yml deleted file mode 100644 index b50b4bc..0000000 --- a/ansible/playbooks/ioc-start.yml +++ /dev/null @@ -1,16 +0,0 @@ -# TODO: ansible seems to be very slow on this machine -# each step takes quite a while (e.g. cloning repo, which -# is significantly faster on other machines). ---- -- name: Start an IOC - hosts: vserv - gather_facts: false - - vars: - workspace_dir: "/tmp/hla_framework_bd" - # Controller-side checkout, where config/environments.yml is read from - repo_root: "{{ playbook_dir }}/../.." - - tasks: - - name: Run ioc start tasks - ansible.builtin.import_tasks: ../tasks/ioc/ioc-start.yml diff --git a/ansible/tasks/ioc/ioc-install.yml b/ansible/tasks/ioc/ioc-install.yml deleted file mode 100644 index d991c1c..0000000 --- a/ansible/tasks/ioc/ioc-install.yml +++ /dev/null @@ -1,66 +0,0 @@ ---- -- name: Init service name - ansible.builtin.import_tasks: ../utils/init-service-name.yml - -- name: Clone repo - ansible.builtin.import_tasks: ../utils/clone-repo.yml - -- name: Get and set ioc_port - ansible.builtin.import_tasks: ../utils/get-ioc-port.yml - -- name: Load deploy-time substitutions - ansible.builtin.import_tasks: ../utils/load-substitutions.yml - -- name: "[{{ target_service_name_lower }}] Establish environment naming conventions" - ansible.builtin.set_fact: - service_name_upper: "{{ target_service_name_lower | upper }}" - -- name: "[{{ target_service_name_lower }}] Define file paths" - ansible.builtin.set_fact: - ioc_base_dir: "{{ workspace_dir }}/services/{{ target_service_name_lower }}/current/ioc" - orig_subs_name: "AGEBD-CPCL-{{ service_name_upper }}_main.subs" - target_subs_name: "AGEBD-CPCL-{{ service_name_upper }}{{ agebd_env_suffix_upper }}_main.subs" - orig_params_name: "AGEBD-CPCL-{{ service_name_upper }}_parameters.yaml" - target_params_name: "AGEBD-CPCL-{{ service_name_upper }}{{ agebd_env_suffix_upper }}_parameters.yaml" - -# Rename the subs file if we are in DEV -- name: "[{{ target_service_name_lower }}] Rename substitution file for environment" - ansible.builtin.command: - cmd: "mv {{ ioc_base_dir }}/{{ orig_subs_name }} {{ ioc_base_dir }}/{{ target_subs_name }}" - removes: "{{ ioc_base_dir }}/{{ orig_subs_name }}" # Only runs if the original file exists - -# Rename the paramters file if we are in DEV -- name: "[{{ target_service_name_lower }}] Rename parameters file for environment" - ansible.builtin.command: - cmd: "mv {{ ioc_base_dir }}/{{ orig_params_name }} {{ ioc_base_dir }}/{{ target_params_name }}" - removes: "{{ ioc_base_dir }}/{{ orig_params_name }}" # Only runs if the original file exists - -# Every @@token@@ is resolved from the central token table -- the token list -# is not repeated here, so a new token needs no change to this file. -- name: "[{{ target_service_name_lower }}] Collect deploy-time token values" - ansible.builtin.set_fact: - deploy_tokens: "{{ env_tokens | combine({'ioc_port': ioc_port}) }}" - -- name: "[{{ target_service_name_lower }}] Substitute deploy-time tokens in the IOC files" - ansible.builtin.replace: - path: "{{ item.0 }}" - regexp: '@@\s*{{ item.1.key }}\s*@@' - replace: "{{ item.1.value | string }}" - loop: >- - {{ [ioc_base_dir ~ '/' ~ target_subs_name, ioc_base_dir ~ '/' ~ target_params_name] - | product(deploy_tokens | dict2items) | list }} - loop_control: - label: "{{ item.0 | basename }} :: {{ item.1.key }}" - -- name: "[{{ target_service_name_lower }}] Run ioc install" - ansible.builtin.shell: - cmd: "ioc install -V --facility sls --ioc AGEBD-CPCL-{{ service_name_upper }}{{ agebd_env_suffix_upper }} --clean" - chdir: "{{ ioc_base_dir }}" - changed_when: true - register: ioc_install_result - # Force a pipeline failure if the command output contains error keywords - # (ioc install ... returns exit code 0 even on failure...) - failed_when: > - ioc_install_result.rc != 0 or - "[error]" in ioc_install_result.stderr or - "Unable to write" in ioc_install_result.stderr diff --git a/ansible/tasks/ioc/ioc-restart.yml b/ansible/tasks/ioc/ioc-restart.yml deleted file mode 100644 index 8d846c9..0000000 --- a/ansible/tasks/ioc/ioc-restart.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -- name: Init service name - ansible.builtin.import_tasks: ../utils/init-service-name.yml - -- name: Clone repo - ansible.builtin.import_tasks: ../utils/clone-repo.yml - -- name: Load deploy-time substitutions - ansible.builtin.import_tasks: ../utils/load-substitutions.yml - -- name: "[{{ target_service_name_lower }}] Establish environment naming conventions" - ansible.builtin.set_fact: - service_name_upper: "{{ target_service_name_lower | upper }}" - -- name: "[{{ target_service_name_lower }}] Define file paths" - ansible.builtin.set_fact: - ioc_base_dir: "{{ workspace_dir }}/services/{{ target_service_name_lower }}/current/ioc" - -- name: "[{{ target_service_name_lower }}] Restart IOC" - ansible.builtin.shell: - cmd: "ioc restart AGEBD-CPCL-{{ service_name_upper }}{{ agebd_env_suffix_upper }}" - chdir: "{{ ioc_base_dir }}" - changed_when: true diff --git a/ansible/tasks/ioc/ioc-start.yml b/ansible/tasks/ioc/ioc-start.yml deleted file mode 100644 index 28d3e64..0000000 --- a/ansible/tasks/ioc/ioc-start.yml +++ /dev/null @@ -1,17 +0,0 @@ ---- -- name: Init service name - ansible.builtin.import_tasks: ../utils/init-service-name.yml - -- name: Clone repo - ansible.builtin.import_tasks: ../utils/clone-repo.yml - -- name: Get and set ioc_port - ansible.builtin.import_tasks: ../utils/get-ioc-port.yml - -- name: "[{{ ioc_port }}] Start IOC" - ansible.builtin.shell: | - sudo shellbox status - sudo shellbox reload - sudo shellbox status - sudo shellbox start {{ ioc_port }} - changed_when: true diff --git a/ansible/tasks/service/service-deploy-and-restart.yml b/ansible/tasks/service/service-deploy-and-restart.yml deleted file mode 100644 index 7509cda..0000000 --- a/ansible/tasks/service/service-deploy-and-restart.yml +++ /dev/null @@ -1,61 +0,0 @@ ---- -- name: Initialize service name - ansible.builtin.set_fact: - # Uses preferred_service_name_lower if defined in vars, otherwise falls back to the CLI extra-vars - target_service_name_lower: "{{ preferred_service_name_lower | default(service_name_lower) }}" - -- name: Load deploy-time substitutions - ansible.builtin.import_tasks: ../utils/load-substitutions.yml - -- name: "[{{ target_service_name_lower }}] Define service specific paths and names" - ansible.builtin.set_fact: - svc_current_dir: "/sls/bd/hla/{{ agebd_env }}/services/{{ target_service_name_lower }}/current" - service_unit_name: "AGEBD-SERVICE-{{ target_service_name_lower | upper }}.service" - -- name: "[{{ target_service_name_lower }}] Ensure target directory structure exists" - ansible.builtin.file: - path: "{{ svc_current_dir }}" - state: directory - # ansible.builtin.shell: | - # mkdir -p "{{ svc_current_dir }}" - -# TODO: make sure this is what we want (essentially wipes existing dest dir). Does it remove .venv? -- name: "[{{ target_service_name_lower }}] Sync service deployment files" - ansible.posix.synchronize: - src: "{{ repo_root }}/services/{{ target_service_name_lower }}/current/" - dest: "{{ svc_current_dir }}/" - delete: yes # This removes stale files on the target that no longer exist locally - recursive: yes - -# Every @@token@@ is resolved from the central token table -- the token list -# is not repeated here, so a new token needs no change to this file. -- name: "[{{ target_service_name_lower }}] Substitute deploy-time tokens in the systemd unit" - ansible.builtin.replace: - path: "{{ svc_current_dir }}/systemd/{{ service_unit_name }}" - regexp: '@@\s*{{ item.key }}\s*@@' - replace: "{{ item.value | string }}" - loop: "{{ env_tokens | dict2items }}" - loop_control: - label: "{{ item.key }}" - - # systemctl --user link --force "./{{ service_unit_name }}" -- name: "[{{ target_service_name_lower }}] Symlink systemd unit file (Absolute Path)" - ansible.builtin.file: - src: "{{ svc_current_dir }}/systemd/{{ service_unit_name }}" - dest: "~/.config/systemd/user/{{ service_unit_name }}" - state: link - force: yes # This overwrites the broken/existing symlink cleanly without error - - # export XDG_RUNTIME_DIR=/run/user/$(id -u) - # systemctl --user daemon-reload - # systemctl --user enable "{{ service_unit_name }}" - # systemctl --user restart "{{ service_unit_name }}" -- name: "[{{ target_service_name_lower }}] Enable and Restart Systemd Service" - ansible.builtin.systemd_service: - name: "{{ service_unit_name }}" - scope: user - daemon_reload: yes - enabled: yes - state: restarted - environment: - XDG_RUNTIME_DIR: "/run/user/{{ ansible_user_uid }}" diff --git a/ansible/tasks/utils/clone-repo.yml b/ansible/tasks/utils/clone-repo.yml deleted file mode 100644 index 3c782bb..0000000 --- a/ansible/tasks/utils/clone-repo.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: Clone this repository - ansible.builtin.git: - repo: "git@gitea.psi.ch:sls/hla_framework_bd.git" - dest: "{{ workspace_dir }}" - accept_hostkey: true - version: "{{ branch_name }}" - force: true diff --git a/ansible/tasks/utils/get-ioc-port.yml b/ansible/tasks/utils/get-ioc-port.yml deleted file mode 100644 index 2f18c69..0000000 --- a/ansible/tasks/utils/get-ioc-port.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -- name: Read the service registry file - ansible.builtin.slurp: - src: "{{ workspace_dir }}/config/services_registry.yml" - register: remote_registry_file - -- name: Parse the service registry YAML content into a fact - ansible.builtin.set_fact: - services_registry: "{{ remote_registry_file.content | b64decode | from_yaml }}" - -- name: Extract the port from the services registry and save it as a persistent fact - ansible.builtin.set_fact: - ioc_port: "{{ services_registry.services | selectattr('name', 'equalto', target_service_name_lower) | map(attribute='ioc_port') | first | default(none) }}" - -- name: Fail if the service or port wasn't found in the registry - ansible.builtin.fail: - msg: "Service '{{ target_service_name_lower }}' not found in services registry or port not set" - when: ioc_port | trim == "" or ioc_port is none diff --git a/ansible/tasks/utils/init-service-name.yml b/ansible/tasks/utils/init-service-name.yml deleted file mode 100644 index 46ad173..0000000 --- a/ansible/tasks/utils/init-service-name.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- name: Initialize service name - ansible.builtin.set_fact: - # Uses preferred_service_name_lower if defined in vars, otherwise falls back to the CLI extra-vars - target_service_name_lower: "{{ preferred_service_name_lower | default(service_name_lower) }}" diff --git a/ansible/tasks/utils/load-substitutions.yml b/ansible/tasks/utils/load-substitutions.yml deleted file mode 100644 index f63a26a..0000000 --- a/ansible/tasks/utils/load-substitutions.yml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Load the deploy-time token values for {{ agebd_env }} from the single source -# of truth, config/environments.yml. -# -# The inventory says which environment it is; this file says what that -# environment means. Do not reintroduce suffix values into the inventories. -# -# include_vars reads on the controller, where the repo checkout lives, so this -# works for both local-connection plays and plays targeting remote hosts. -# -# Ansible substitutes tokens with ansible.builtin.replace rather than calling -# agebd.subs, because these tasks also run on hosts that have no uv or Python -# environment for this repo. The values and the token list still come from the -# central config, so there is nothing to keep in sync by hand. - -- name: Load the central substitution config - ansible.builtin.include_vars: - file: "{{ repo_root }}/config/environments.yml" - name: substitutions - -- name: Fail if this environment is not declared centrally - ansible.builtin.fail: - msg: >- - Environment '{{ agebd_env }}' is not declared in config/environments.yml. - Declared: {{ substitutions.environments.keys() | list | join(', ') }} - when: agebd_env not in substitutions.environments - -- name: Select the deploy-time token values for this environment - ansible.builtin.set_fact: - env_tokens: "{{ substitutions.environments[agebd_env] }}" - agebd_env_suffix_upper: "{{ substitutions.environments[agebd_env].agebd_env_suffix_upper }}" - agebd_env_suffix_lower: "{{ substitutions.environments[agebd_env].agebd_env_suffix_lower }}" diff --git a/bin/start_service.sh b/bin/start_service.sh index 40dfde6..dd888cc 100755 --- a/bin/start_service.sh +++ b/bin/start_service.sh @@ -7,6 +7,10 @@ set -- # PSI_FACILITY=SLS SERVICES_DIR=/sls/bd/hla/${AGEBD_ENV}/services +# These two derivations are the one place that deliberately does not use +# agebd.names.ServiceName: this runs on the deploy host before any Python +# environment for the service exists (the venv is built further down), so it +# cannot import agebd. Keep the rules here in step with agebd/names.py. SVC_NAME_LOWER=${SVC_NAME,,} SVC_NAME_LOWER_UNDERSCORES="${SVC_NAME_LOWER//-/_}" SVC_DIR=${SERVICES_DIR}/${SVC_NAME_LOWER} diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 0fa047f..e7728cf 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -9,8 +9,6 @@ authors = [ dependencies = [ "agebd", - "ansible>=8.7.0", - "ansible-runner>=2.4.3", "copier>=9.10.3", "pydantic>=2.13.4", "pydantic-settings>=2.14.2", diff --git a/cli/src/core/ansible.py b/cli/src/core/ansible.py deleted file mode 100644 index 445dca5..0000000 --- a/cli/src/core/ansible.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import sys - -import ansible_runner -import typer - -from agebd.utils import get_git_root -from core.enums import IOC_ENV - -ioc = typer.Typer(no_args_is_help=True) - -REPO_ROOT = get_git_root(__file__) - - -def run_playbook(playbook: str, env: IOC_ENV, vars: list[str]): - env_str = str(env) - if env_str not in ["dev", "prod"]: - raise ValueError("env must be 'dev' or 'prod'") - - current_env = os.environ.copy() - current_env["ANSIBLE_CONFIG"] = f"{REPO_ROOT}/ansible/ansible.cfg" - - extra_vars = [f"--extra-vars={var}" for var in vars] - out, err, rc = ansible_runner.run_command( - executable_cmd=f"ansible-playbook", - cmdline_args=[ - playbook, - "-i", - f"{REPO_ROOT}/ansible/inventories/{env_str}/hosts.yml", - *extra_vars, - "-v", - ], - envvars=current_env, - input_fd=sys.stdin, - output_fd=sys.stdout, - error_fd=sys.stderr, - ) - print("rc: {}".format(rc)) - print("out: {}".format(out)) - print("err: {}".format(err)) diff --git a/cli/src/core/enums.py b/cli/src/core/enums.py index fc6dae1..2379c23 100644 --- a/cli/src/core/enums.py +++ b/cli/src/core/enums.py @@ -6,14 +6,6 @@ class ServiceStatus(str, Enum): DISABLED = "disabled" -class IOC_ENV(str, Enum): - PROD = "prod" - DEV = "dev" - - def __str__(self) -> str: - return self.value - - class GuiOption(str, Enum): NEW = "new" EXISTING = "existing" diff --git a/cli/src/ioc.py b/cli/src/ioc.py deleted file mode 100644 index d0cd20c..0000000 --- a/cli/src/ioc.py +++ /dev/null @@ -1,112 +0,0 @@ -import typer - -from agebd.utils import get_git_root -from config.paths import Paths -from core.ansible import run_playbook -from core.enums import IOC_ENV -from models import ServiceRegistry - -ioc = typer.Typer(no_args_is_help=True) - -REPO_ROOT = get_git_root(__file__) - -ANSIBLE_PLAYBOOKS_DIR = REPO_ROOT / "ansible" / "playbooks" -IOC_PLAYBOOK_INSTALL = ANSIBLE_PLAYBOOKS_DIR / "ioc-install.yml" -IOC_PLAYBOOK_RESTART = ANSIBLE_PLAYBOOKS_DIR / "ioc-restart.yml" -IOC_PLAYBOOK_START = ANSIBLE_PLAYBOOKS_DIR / "ioc-start.yml" - -SERVICE_NAME_HELP = "Service name in lower case" - - -def _resolve_branch_name(branch_name: str | None, service_name_lower: str) -> str: - return branch_name or f"feature/add-service-{service_name_lower}" - - -def _get_registry_services(): - paths = Paths() - registry = ServiceRegistry.read_from_file(paths.service_registry_file) - return registry.get_services() - - -@ioc.command(no_args_is_help=True) -def install( - env: IOC_ENV = typer.Option(..., "--env"), - service_name_lower: str = typer.Option(..., "--service-name", "-s", help=SERVICE_NAME_HELP), - branch_name: str | None = typer.Option( - None, "--branch-name", "-b", help="Default is 'feature/add-service-{service-name}'" - ), -): - branch_name = _resolve_branch_name(branch_name, service_name_lower) - run_playbook( - playbook=str(IOC_PLAYBOOK_INSTALL), - env=env, - vars=[f"branch_name={branch_name}", f"service_name_lower={service_name_lower}"], - ) - - -@ioc.command(no_args_is_help=True) -def install_all( - env: IOC_ENV = typer.Option(..., "--env"), -): - for service in _get_registry_services(): - run_playbook( - playbook=str(IOC_PLAYBOOK_INSTALL), - env=env, - vars=["branch_name=main", f"service_name_lower={service.name_lower}"], - ) - - -@ioc.command(no_args_is_help=True) -def start( - env: IOC_ENV = typer.Option(..., "--env"), - service_name_lower: str = typer.Option(..., "--service-name", "-s", help=SERVICE_NAME_HELP), - branch_name: str | None = typer.Option( - None, "--branch-name", "-b", help="Default is 'feature/add-service-{service-name}'" - ), -): - branch_name = _resolve_branch_name(branch_name, service_name_lower) - run_playbook( - playbook=str(IOC_PLAYBOOK_START), - env=env, - vars=[f"service_name_lower={service_name_lower}", f"branch_name={branch_name}"], - ) - - -@ioc.command() -def start_all( - env: IOC_ENV = typer.Option(..., "--env"), -): - for service in _get_registry_services(): - run_playbook( - playbook=str(IOC_PLAYBOOK_START), - env=env, - vars=[f"service_name_lower={service.name_lower}"], - ) - - -@ioc.command(no_args_is_help=True) -def restart( - env: IOC_ENV = typer.Option(..., "--env"), - service_name_lower: str = typer.Option(..., "--service-name", "-s", help=SERVICE_NAME_HELP), - branch_name: str | None = typer.Option( - None, "--branch-name", "-b", help="Default is 'feature/add-service-{service-name}'" - ), -): - branch_name = _resolve_branch_name(branch_name, service_name_lower) - run_playbook( - playbook=str(IOC_PLAYBOOK_RESTART), - env=env, - vars=[f"branch_name={branch_name}", f"service_name_lower={service_name_lower}"], - ) - - -@ioc.command(no_args_is_help=True) -def restart_all( - env: IOC_ENV = typer.Option(..., "--env"), -): - for service in _get_registry_services(): - run_playbook( - playbook=str(IOC_PLAYBOOK_RESTART), - env=env, - vars=["branch_name=main", f"service_name_lower={service.name_lower}"], - ) diff --git a/cli/src/main.py b/cli/src/main.py index a23e705..7a5c16e 100644 --- a/cli/src/main.py +++ b/cli/src/main.py @@ -2,7 +2,6 @@ import logging import typer -import ioc import service logger = logging.getLogger(__name__) @@ -10,7 +9,6 @@ logger = logging.getLogger(__name__) cli = typer.Typer(no_args_is_help=True, add_completion=False) cli.add_typer(service.service, name="service") -cli.add_typer(ioc.ioc, name="ioc") if __name__ == "__main__": diff --git a/cli/src/models/service.py b/cli/src/models/service.py index e7a1b49..92a5d95 100644 --- a/cli/src/models/service.py +++ b/cli/src/models/service.py @@ -1,34 +1,48 @@ -import re - +from agebd.names import ServiceName from pydantic import BaseModel, field_validator from core.enums import ServiceStatus class Service(BaseModel): + """ + A service's registry entry. + + Every spelling of the name is delegated to agebd.names.ServiceName, which + is also what the CI and deploy scripts use. + """ + name: str ioc_port: int status: ServiceStatus + @property + def service_name(self) -> ServiceName: + return ServiceName(self.name) + @property def name_camel(self): - return self.name + return self.service_name.camel @property def name_upper(self): - return self.name.upper() + return self.service_name.upper @property def name_lower(self): - return self.name.lower() + return self.service_name.lower @property def name_lower_underscores(self): - return self.name.replace("-", "_").lower() + return self.service_name.lower_underscores @property def dir_name(self): - return self.name.lower() + return self.service_name.dir_name + + @property + def ioc_name(self): + return self.service_name.ioc_name def to_dict(self): return {"name": self.name_camel, "ioc_port": self.ioc_port, "status": str(self.status)} @@ -39,8 +53,5 @@ class Service(BaseModel): @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 uppercase and lowercase letters, numbers, and dashes" - ) + ServiceName(value) return value diff --git a/cli/src/models/service_registry.py b/cli/src/models/service_registry.py index 011238e..e40f516 100644 --- a/cli/src/models/service_registry.py +++ b/cli/src/models/service_registry.py @@ -3,6 +3,8 @@ from pathlib import Path import yaml from pydantic import BaseModel +from agebd.subs import find_service_entry + from core.enums import ServiceStatus from models.service import Service @@ -26,12 +28,15 @@ class ServiceRegistry(BaseModel): return [Service(**svc_data) for svc_data in self.services] def assert_service_exists(self, name: str) -> Service: - for svc_data in self.services: - service = Service(**svc_data) - if name == service.name_camel: - return service - - raise ValueError(f"Could not find service with name: {name}") + """ + Look a service up by name. Matching goes through + agebd.subs.find_service_entry so that the CLI, the CI scripts and the + deploy scripts all agree on what "the service named X" means. + """ + entry = find_service_entry(self.services, name) + if entry is None: + raise ValueError(f"Could not find service with name: {name}") + return Service(**entry) def add_service(self, name: str) -> Service: svc = Service( diff --git a/cli/uv.lock b/cli/uv.lock index 29a62ec..0997b49 100644 --- a/cli/uv.lock +++ b/cli/uv.lock @@ -39,8 +39,6 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "agebd" }, - { name = "ansible" }, - { name = "ansible-runner" }, { name = "copier" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -62,8 +60,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "agebd", editable = "../packages/agebd" }, - { name = "ansible", specifier = ">=8.7.0" }, - { name = "ansible-runner", specifier = ">=2.4.3" }, { name = "copier", specifier = ">=9.10.3" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-settings", specifier = ">=2.14.2" }, @@ -100,49 +96,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "ansible" -version = "10.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ansible-core" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d4/64/29fdff6fe7682342adb54802c1cd90b2272d382e1743089af88f90a1d986/ansible-10.7.0.tar.gz", hash = "sha256:59d29e3de1080e740dfa974517d455217601b16d16880314d9be26145c68dc22", size = 41256974, upload-time = "2024-12-03T18:04:25.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/95/cb8944902a2cdd94b1e19ff73695548679a388b9c473dc63c8dc64ffea3a/ansible-10.7.0-py3-none-any.whl", hash = "sha256:0089f08e047ceb70edd011be009f5c6273add613fbe491e9697c0556c989d8ea", size = 51576038, upload-time = "2024-12-03T18:04:20.065Z" }, -] - -[[package]] -name = "ansible-core" -version = "2.17.14" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pyyaml" }, - { name = "resolvelib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ff/80/2925a0564f6f99a8002c3be3885b83c3a1dc5f57ebf00163f528889865f5/ansible_core-2.17.14.tar.gz", hash = "sha256:7c17fee39f8c29d70e3282a7e9c10bd70d5cd4fd13ddffc5dcaa52adbd142ff8", size = 3119687, upload-time = "2025-09-08T18:28:03.158Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/29/d694562f1a875b50aa74f691521fe493704f79cf1938cd58f28f7e2327d2/ansible_core-2.17.14-py3-none-any.whl", hash = "sha256:34a49582a57c2f2af17ede2cefd3b3602a2d55d22089f3928570d52030cafa35", size = 2189656, upload-time = "2025-09-08T18:28:00.375Z" }, -] - -[[package]] -name = "ansible-runner" -version = "2.4.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "pexpect" }, - { name = "python-daemon" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/68/4f/62222b42b52cc771db51ccc77ffac19cc5f0196ff61de7c93585845a819b/ansible_runner-2.4.3.tar.gz", hash = "sha256:5f3025529bb968fdc3b627457dd8d418dbf9d53bc73735d50e69c28544d53031", size = 154148, upload-time = "2026-03-16T18:42:14.861Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/89/fa7f81db6134caa455c9351373ee934b5fdeed212eb939d6c42d80d683d7/ansible_runner-2.4.3-py3-none-any.whl", hash = "sha256:cdac6daa151a50084ffda710e769db23fa975fc0507796191d7708831b286e37", size = 80257, upload-time = "2026-03-16T18:42:13.72Z" }, -] - [[package]] name = "asttokens" version = "3.0.2" @@ -152,29 +105,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, ] -[[package]] -name = "cffi" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, - { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, - { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, - { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, - { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, - { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, - { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, - { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, - { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -209,44 +139,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/2c/68dec88ee26f96af2d4d8ee9d8567313a0826f4fd6bc5ae4527be670d354/copier-9.17.0-py3-none-any.whl", hash = "sha256:fe4e7b59faf4c0e1386eccbb79c6797e6df33ca20d5de49a8372571d8669f2ad", size = 65954, upload-time = "2026-07-13T14:33:03.301Z" }, ] -[[package]] -name = "cryptography" -version = "49.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, -] - [[package]] name = "decorator" version = "5.3.1" @@ -408,15 +300,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/b9/313e8f2f2e9517ae050a692ae7b3e4b3f17cc5e6dfea0db51fe14e586580/jinja2_ansible_filters-1.3.2-py3-none-any.whl", hash = "sha256:e1082f5564917649c76fed239117820610516ec10f87735d0338688800a55b34", size = 18975, upload-time = "2022-06-30T14:08:49.571Z" }, ] -[[package]] -name = "lockfile" -version = "0.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/47/72cb04a58a35ec495f96984dddb48232b551aafb95bde614605b754fe6f7/lockfile-0.12.2.tar.gz", hash = "sha256:6aed02de03cba24efabcd600b30540140634fc06cfa603822d508d5361e9f799", size = 20874, upload-time = "2015-11-25T18:29:58.279Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/22/9460e311f340cb62d26a38c419b1381b8593b0bb6b5d1f056938b086d362/lockfile-0.12.2-py2.py3-none-any.whl", hash = "sha256:6c3cb24f344923d30b2785d5ad75182c8ea7ac1b6171b08657258ec7429d50fa", size = 13564, upload-time = "2015-11-25T18:29:51.462Z" }, -] - [[package]] name = "markdown-it-py" version = "4.2.0" @@ -599,15 +482,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -750,18 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] -[[package]] -name = "python-daemon" -version = "3.1.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "lockfile" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/37/4f10e37bdabc058a32989da2daf29e57dc59dbc5395497f3d36d5f5e2694/python_daemon-3.1.2.tar.gz", hash = "sha256:f7b04335adc473de877f5117e26d5f1142f4c9f7cd765408f0877757be5afbf4", size = 71576, upload-time = "2024-12-03T08:41:07.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/3c/b88167e2d6785c0e781ee5d498b07472aeb9b6765da3b19e7cc9e0813841/python_daemon-3.1.2-py3-none-any.whl", hash = "sha256:b906833cef63502994ad48e2eab213259ed9bb18d54fa8774dcba2ff7864cec6", size = 30872, upload-time = "2024-12-03T08:41:03.322Z" }, -] - [[package]] name = "python-dotenv" version = "1.2.2" @@ -800,15 +662,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] -[[package]] -name = "resolvelib" -version = "1.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/10/f699366ce577423cbc3df3280063099054c23df70856465080798c6ebad6/resolvelib-1.0.1.tar.gz", hash = "sha256:04ce76cbd63fded2078ce224785da6ecd42b9564b1390793f64ddecbe997b309", size = 21065, upload-time = "2023-03-09T05:10:38.292Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fc/e9ccf0521607bcd244aa0b3fbd574f71b65e9ce6a112c83af988bbbe2e23/resolvelib-1.0.1-py2.py3-none-any.whl", hash = "sha256:d2da45d1a8dfee81bdd591647783e340ef3bcb104b54c383f70d422ef5cc7dbf", size = 17194, upload-time = "2023-03-09T05:10:36.214Z" }, -] - [[package]] name = "rich" version = "15.0.0" diff --git a/config/environments.yml b/config/environments.yml index bf3707a..e8b906e 100644 --- a/config/environments.yml +++ b/config/environments.yml @@ -65,11 +65,9 @@ locations: - templates - cli/tests/fixtures # The substitution machinery itself: code that renders, generates or - # documents tokens. Naming a token here is expected; nothing is resolved. - # These paths use {{ }} as their own template language (Ansible, copier), so - # the leftover-delimiter check does not apply to them. + # documents tokens. Naming a token here is expected; nothing is resolved, + # and the leftover-delimiter check does not apply. references: - - ansible/tasks/* - .gitea/scripts - cli/src/models - packages/agebd/src/agebd diff --git a/docs/developer/ansible.md b/docs/developer/ansible.md deleted file mode 100644 index 28d55c1..0000000 --- a/docs/developer/ansible.md +++ /dev/null @@ -1,36 +0,0 @@ -# Ansible - -## Running a playbook manually - -Set vars on command line: - -``` -ansible-playbook ansible/playbooks/add-new-service.yml \ - -i ansible/hosts.yml \ - --extra-vars="repo_root=${{ github.workspace }} agebd_env=dev service_name_lower=" \ - -v -``` - -### Install an IOC -``` -ansible-playbook ansible/playbooks/ioc-install.yml \ - -i ansible/hosts.yml \ - --extra-vars="agebd_env=dev branch_name=feature/add-service-playground service_name_lower=playground ioc_port=50003" \ - -v -``` - -### Start an IOC -``` -ansible-playbook ansible/playbooks/ioc-start.yml \ - -i ansible/hosts.yml \ - --extra-vars="service_name_lower=playground ioc_port=50003" \ - -v -``` - -### Restart an IOC -``` -ansible-playbook ansible/playbooks/ioc-restart.yml \ - -i ansible/hosts.yml \ - --extra-vars="agebd_env=dev branch_name=feature/add-service-playground service_name_lower=playground" \ - -v -``` diff --git a/packages/agebd/src/agebd/names.py b/packages/agebd/src/agebd/names.py new file mode 100644 index 0000000..c2f2654 --- /dev/null +++ b/packages/agebd/src/agebd/names.py @@ -0,0 +1,113 @@ +""" +The one definition of how a service name is spelled in each context. + +A service has a single canonical CamelCase name, recorded in +config/services_registry.yml. Everything else is derived from it by a fixed +rule: the directory it lives in, its Python module, its EPICS IOC, its systemd +unit. Those rules live here so that the CLI, the CI scripts and the deploy +scripts cannot drift apart on them. + +The environment suffix is deliberately not part of any name here. It is a +deploy-time token (see agebd.subs), applied to the base names below. +""" + +import re + +#: Prefix of every EPICS IOC in this framework. +IOC_PREFIX = "AGEBD-CPCL" + +#: Prefix of every systemd unit in this framework. +SYSTEMD_UNIT_PREFIX = "AGEBD-SERVICE" + +#: Prefix of every service's Python package. +PACKAGE_PREFIX = "agebd" + +VALID_NAME = re.compile(r"^[a-zA-Z0-9-]+$") + + +class InvalidServiceName(ValueError): + """The name is not a legal service name.""" + + +class ServiceName: + """ + A service's canonical name and every form derived from it. + + >>> name = ServiceName("TuneFBx") + >>> name.dir_name, name.module_name, name.ioc_name + ('tunefbx', 'agebd_tunefbx', 'AGEBD-CPCL-TUNEFBX') + """ + + def __init__(self, name: str) -> None: + if not isinstance(name, str) or not VALID_NAME.match(name): + raise InvalidServiceName( + f"Invalid service name {name!r}: a service name may only contain " + f"uppercase and lowercase letters, numbers, and dashes." + ) + self._name = name + + @property + def camel(self) -> str: + """The canonical name, as written in the registry and shown in the GUI.""" + return self._name + + @property + def upper(self) -> str: + return self._name.upper() + + @property + def lower(self) -> str: + return self._name.lower() + + @property + def lower_underscores(self) -> str: + return self.lower.replace("-", "_") + + @property + def dir_name(self) -> str: + """The directory under services/ that holds this service.""" + return self.lower + + @property + def module_name(self) -> str: + """The Python package implementing this service.""" + return f"{PACKAGE_PREFIX}_{self.lower_underscores}" + + @property + def ioc_name(self) -> str: + """ + The IOC's base name. Deployments append the environment suffix token. + """ + return f"{IOC_PREFIX}-{self.upper}" + + @property + def systemd_unit_name(self) -> str: + """ + The systemd unit file name. Unit names carry no environment suffix: + dev and prod run on different hosts. + """ + return f"{SYSTEMD_UNIT_PREFIX}-{self.upper}.service" + + def matches(self, other) -> bool: + """ + Whether ``other`` names this service. Comparison is case-insensitive, + because callers hold the name in whatever casing their context uses. + """ + try: + return ServiceName(str(other)).lower == self.lower + except InvalidServiceName: + return False + + def __str__(self) -> str: + return self.camel + + def __repr__(self) -> str: + return f"ServiceName({self.camel!r})" + + def __eq__(self, other) -> bool: + if isinstance(other, ServiceName): + return self.lower == other.lower + return NotImplemented + + def __hash__(self) -> int: + return hash(self.lower) diff --git a/packages/agebd/src/agebd/subs.py b/packages/agebd/src/agebd/subs.py index dd4b1e1..28d32a3 100644 --- a/packages/agebd/src/agebd/subs.py +++ b/packages/agebd/src/agebd/subs.py @@ -36,6 +36,7 @@ from typing import Iterable, Iterator, Optional, Union import yaml +from agebd.names import InvalidServiceName, ServiceName from agebd.utils import get_git_root CONFIG_DIR_ENV_VAR = "AGEBD_CONFIG_DIR" @@ -306,19 +307,32 @@ class Substitutions: def read_ioc_port(registry_file: Union[str, Path], service_name: str) -> int: """ Look up a service's IOC port in the services registry. - - Registry names are CamelCase; callers pass whatever casing they have, so - the comparison is case-insensitive. """ with open(registry_file) as f: registry = yaml.safe_load(f) or {} - wanted = service_name.lower() - for entry in registry.get("services") or []: - if str(entry.get("name", "")).lower() == wanted: - return entry["ioc_port"] + entry = find_service_entry(registry.get("services") or [], service_name) + if entry is None: + raise SubstitutionError(f"Service '{service_name}' not found in {registry_file}") + return entry["ioc_port"] - raise SubstitutionError(f"Service '{service_name}' not found in {registry_file}") + +def find_service_entry(services: Iterable, service_name: str): + """ + Find a service's registry entry by name, or None. + + Registry names are CamelCase; callers hold whatever casing their context + uses, so matching goes through agebd.names.ServiceName. + """ + try: + wanted = ServiceName(service_name) + except InvalidServiceName: + return None + + for entry in services: + if wanted.matches(entry.get("name", "")): + return entry + return None def find_tokens(text: str) -> set: diff --git a/packages/agebd/tests/test_names.py b/packages/agebd/tests/test_names.py new file mode 100644 index 0000000..8973ed1 --- /dev/null +++ b/packages/agebd/tests/test_names.py @@ -0,0 +1,66 @@ +import pytest + +from agebd.names import InvalidServiceName, ServiceName + + +@pytest.mark.parametrize( + "name, upper, lower, underscores", + [ + ("Tune", "TUNE", "tune", "tune"), + ("TauBPM", "TAUBPM", "taubpm", "taubpm"), + ("TuneFBx", "TUNEFBX", "tunefbx", "tunefbx"), + ("OpticsFF-X02S", "OPTICSFF-X02S", "opticsff-x02s", "opticsff_x02s"), + ], +) +def test_case_forms(name, upper, lower, underscores): + service = ServiceName(name) + + assert service.camel == name + assert service.upper == upper + assert service.lower == lower + assert service.lower_underscores == underscores + + +def test_derived_names(): + service = ServiceName("OpticsFF-X02S") + + assert service.dir_name == "opticsff-x02s" + assert service.module_name == "agebd_opticsff_x02s" + assert service.ioc_name == "AGEBD-CPCL-OPTICSFF-X02S" + assert service.systemd_unit_name == "AGEBD-SERVICE-OPTICSFF-X02S.service" + + +def test_derived_names_carry_no_environment_suffix(): + """The suffix is a deploy-time token, applied to these base names.""" + service = ServiceName("Tune") + + assert "DEV" not in service.ioc_name + assert "DEV" not in service.systemd_unit_name + + +@pytest.mark.parametrize("name", ["Tune", "tune", "TUNE", "tUnE"]) +def test_matches_is_case_insensitive(name): + assert ServiceName("Tune").matches(name) + + +def test_does_not_match_a_different_service(): + assert not ServiceName("Tune").matches("TuneBump") + + +def test_does_not_match_an_invalid_name(): + assert not ServiceName("Tune").matches("Tune!") + + +@pytest.mark.parametrize("name", ["", "Tune Bump", "Tune_Bump", "Tune!", "Tune/Bump"]) +def test_invalid_names_are_rejected(name): + with pytest.raises(InvalidServiceName): + ServiceName(name) + + +def test_equality_and_hashing_ignore_case(): + assert ServiceName("Tune") == ServiceName("TUNE") + assert len({ServiceName("Tune"), ServiceName("tune")}) == 1 + + +def test_str_is_the_canonical_name(): + assert str(ServiceName("TauBPM")) == "TauBPM" diff --git a/packages/agebd/tests/test_runner.py b/packages/agebd/tests/test_runner.py index 7237ea1..da1129e 100644 --- a/packages/agebd/tests/test_runner.py +++ b/packages/agebd/tests/test_runner.py @@ -1,8 +1,8 @@ from unittest.mock import patch -from agebd.service.pvs import BasePVs from agebd.runner import CallbackRunner, PeriodicRunner from agebd.service.base import BaseService +from agebd.service.pvs import BasePVs class DummyPV: diff --git a/packages/agebd/tests/test_service.py b/packages/agebd/tests/test_service.py index 498ba17..bcf9086 100644 --- a/packages/agebd/tests/test_service.py +++ b/packages/agebd/tests/test_service.py @@ -1,5 +1,5 @@ -from agebd.service.pvs import BasePVs from agebd.service.base import BaseService +from agebd.service.pvs import BasePVs class DummyPV: diff --git a/scripts/ci/get_ioc_port.py b/scripts/ci/get_ioc_port.py index a51e914..51a09e6 100644 --- a/scripts/ci/get_ioc_port.py +++ b/scripts/ci/get_ioc_port.py @@ -1,24 +1,29 @@ +""" +Print a service's IOC port. + + get_ioc_port.py tune + +The lookup itself lives in agebd.subs, which is also what render_subs.py and +the CLI use, so there is only one answer to "which port is this service on". +""" + import sys -import yaml -from agebd.utils import get_git_root - -REPO_ROOT = get_git_root(".") -SERVICES_REGISTRY_FILE = REPO_ROOT / "config" / "services_registry.yml" +from agebd.subs import ( + SERVICES_REGISTRY_FILENAME, + SubstitutionError, + read_ioc_port, + resolve_config_dir, +) -def get_ioc_port(service_name_lower: str): - with open(SERVICES_REGISTRY_FILE) as f: - d = yaml.safe_load(f) - - for service_dict in d["services"]: - if service_dict["name"].lower() == service_name_lower: - return service_dict["ioc_port"] - - raise ValueError(f"Service with lowercase name {service_name_lower} not found") +def get_ioc_port(service_name: str) -> int: + return read_ioc_port(resolve_config_dir() / SERVICES_REGISTRY_FILENAME, service_name) if __name__ == "__main__": - service_name_lower = sys.argv[1] - port = get_ioc_port(service_name_lower) - print(port) + try: + print(get_ioc_port(sys.argv[1])) + except SubstitutionError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/ci/service_names.py b/scripts/ci/service_names.py new file mode 100644 index 0000000..20b245c --- /dev/null +++ b/scripts/ci/service_names.py @@ -0,0 +1,45 @@ +""" +Emit a service's derived names as shell assignments. + + eval "$(service_names.py TauBPM)" + echo "$SYSTEMD_UNIT_NAME" # AGEBD-SERVICE-TAUBPM.service + +Every form comes from agebd.names.ServiceName, so the deploy scripts carry no +copy of the naming conventions. Values are shell-quoted; an invalid service +name exits non-zero and emits nothing, so an unguarded eval cannot silently +leave the caller with empty variables. +""" + +import shlex +import sys + +from agebd.names import InvalidServiceName, ServiceName + +#: Shell variable name -> ServiceName attribute. +EXPORTED = { + "SERVICE_NAME_CAMEL": "camel", + "SERVICE_NAME_UPPER": "upper", + "SERVICE_NAME_LOWER": "lower", + "SERVICE_DIR_NAME": "dir_name", + "SERVICE_MODULE_NAME": "module_name", + "IOC_NAME": "ioc_name", + "SYSTEMD_UNIT_NAME": "systemd_unit_name", +} + + +def shell_assignments(name: str) -> str: + service = ServiceName(name) + return "\n".join( + f"{var}={shlex.quote(getattr(service, attr))}" for var, attr in EXPORTED.items() + ) + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(f"usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + try: + print(shell_assignments(sys.argv[1])) + except InvalidServiceName as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/tests/test_service_names.py b/scripts/tests/test_service_names.py new file mode 100644 index 0000000..a7e00ab --- /dev/null +++ b/scripts/tests/test_service_names.py @@ -0,0 +1,72 @@ +import subprocess +import sys + +import pytest +from agebd.names import InvalidServiceName +from agebd.utils import get_git_root + +from ci.service_names import EXPORTED, shell_assignments + +SCRIPT = get_git_root(__file__) / "scripts" / "ci" / "service_names.py" + + +def run_in_shell(name): + """Eval the output in a real shell and read the variables back.""" + assignments = shell_assignments(name) + readback = "\n".join(f'printf "%s=%s\\n" {var} "${var}"' for var in EXPORTED) + out = subprocess.run( + ["bash", "-c", f"{assignments}\n{readback}"], + capture_output=True, + text=True, + check=True, + ).stdout + return dict(line.split("=", 1) for line in out.strip().splitlines()) + + +def test_emits_every_derived_form(): + values = run_in_shell("TauBPM") + + assert values == { + "SERVICE_NAME_CAMEL": "TauBPM", + "SERVICE_NAME_UPPER": "TAUBPM", + "SERVICE_NAME_LOWER": "taubpm", + "SERVICE_DIR_NAME": "taubpm", + "SERVICE_MODULE_NAME": "agebd_taubpm", + "IOC_NAME": "AGEBD-CPCL-TAUBPM", + "SYSTEMD_UNIT_NAME": "AGEBD-SERVICE-TAUBPM.service", + } + + +def test_dashed_name_survives_the_shell(): + values = run_in_shell("OpticsFF-X02S") + + assert values["SERVICE_DIR_NAME"] == "opticsff-x02s" + assert values["SERVICE_MODULE_NAME"] == "agebd_opticsff_x02s" + assert values["IOC_NAME"] == "AGEBD-CPCL-OPTICSFF-X02S" + + +def test_invalid_name_raises(): + with pytest.raises(InvalidServiceName): + shell_assignments("Tune; rm -rf /") + + +def test_cli_exits_non_zero_and_emits_nothing_on_a_bad_name(): + result = subprocess.run( + [sys.executable, str(SCRIPT), "Tune Bump"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "Invalid service name" in result.stderr + + +def test_cli_requires_exactly_one_argument(): + result = subprocess.run( + [sys.executable, str(SCRIPT)], capture_output=True, text=True, check=False + ) + + assert result.returncode == 2 + assert "usage:" in result.stderr