From 4a73e52a358d03a591339a0a256d133cf4e0432c Mon Sep 17 00:00:00 2001 From: Benjamin Labrecque Date: Tue, 25 Aug 2026 10:35:41 +0200 Subject: [PATCH] refactor: one definition per naming rule, drop ansible (step 5) Consolidates the last duplicated derivations, and removes the ansible tree along with the CLI surface that existed only to drive it. Naming rules: * agebd/names.py holds ServiceName -- the single definition of how a service name is spelled in each context: dir_name, module_name, ioc_name, systemd_unit_name and the case forms. The environment suffix is deliberately not part of it; that stays a deploy-time token applied to these base names. * cli's Service model delegates every name_* property to it, so the CLI and the CI scripts derive names the same way. * scripts/ci/service_names.py emits the derived forms as shell assignments, and deploy-and-restart-service.sh evals them instead of carrying its own tr calls and its own copy of the systemd naming convention. Values are shell-quoted, and an invalid name exits non-zero emitting nothing, so an eval cannot leave empty variables behind. Registry lookups: * agebd.subs.find_service_entry is now the only place that answers "which registry entry is this service", used by read_ioc_port, get_ioc_port.py and cli's ServiceRegistry. get_ioc_port.py no longer parses the registry itself. * That also settles a semantic split: ServiceRegistry matched names case-sensitively while read_ioc_port did not. Both now match case-insensitively via ServiceName, so 'tune', 'Tune' and 'TUNE' resolve to the same service everywhere. Ansible removal: * ansible/ is deleted, along with cli/src/ioc.py, cli/src/core/ ansible.py, the IOC_ENV enum, the ansible and ansible-runner dependencies, and docs/developer/ansible.md. * This removes the 'agebd ioc install|start|restart' commands. They were pure playbook wrappers, so they could not outlive the playbooks; IOC install, start and restart now have no automation path in this repo. bin/start_service.sh keeps deriving its two name forms in bash, with a comment saying why: it runs on the deploy host before the service venv exists, so it cannot import agebd. It is the one deliberate exception. Verified: get_ioc_port.py, cli's ServiceRegistry and agebd's renderer all return 50007 for 'tune', 'Tune' and 'TUNE'; the shell-emitted names match the committed on-disk layout for real services including the dashed OpticsFF-X02S; the CLI runs with only the 'service' command left. Co-Authored-By: Claude Opus 5 --- .gitea/scripts/deploy-and-restart-service.sh | 19 ++- ansible/ansible.cfg | 8 - ansible/collections/requirements.yml | 3 - ansible/inventories/dev/hosts.yml | 19 --- ansible/inventories/prod/hosts.yml | 18 --- ansible/playbooks/add-new-service.yml | 42 ----- .../deploy-and-restart-modified-services.yml | 24 --- ansible/playbooks/ioc-install.yml | 13 -- ansible/playbooks/ioc-restart.yml | 13 -- ansible/playbooks/ioc-start.yml | 16 -- ansible/tasks/ioc/ioc-install.yml | 66 -------- ansible/tasks/ioc/ioc-restart.yml | 23 --- ansible/tasks/ioc/ioc-start.yml | 17 -- .../service/service-deploy-and-restart.yml | 61 -------- ansible/tasks/utils/clone-repo.yml | 8 - ansible/tasks/utils/get-ioc-port.yml | 18 --- ansible/tasks/utils/init-service-name.yml | 5 - ansible/tasks/utils/load-substitutions.yml | 32 ---- bin/start_service.sh | 4 + cli/pyproject.toml | 2 - cli/src/core/ansible.py | 40 ----- cli/src/core/enums.py | 8 - cli/src/ioc.py | 112 ------------- cli/src/main.py | 2 - cli/src/models/service.py | 33 ++-- cli/src/models/service_registry.py | 17 +- cli/uv.lock | 147 ------------------ config/environments.yml | 6 +- docs/developer/ansible.md | 36 ----- packages/agebd/src/agebd/names.py | 113 ++++++++++++++ packages/agebd/src/agebd/subs.py | 30 +++- packages/agebd/tests/test_names.py | 66 ++++++++ packages/agebd/tests/test_runner.py | 2 +- packages/agebd/tests/test_service.py | 2 +- scripts/ci/get_ioc_port.py | 39 +++-- scripts/ci/service_names.py | 45 ++++++ scripts/tests/test_service_names.py | 72 +++++++++ 37 files changed, 393 insertions(+), 788 deletions(-) delete mode 100644 ansible/ansible.cfg delete mode 100644 ansible/collections/requirements.yml delete mode 100644 ansible/inventories/dev/hosts.yml delete mode 100644 ansible/inventories/prod/hosts.yml delete mode 100644 ansible/playbooks/add-new-service.yml delete mode 100644 ansible/playbooks/deploy-and-restart-modified-services.yml delete mode 100644 ansible/playbooks/ioc-install.yml delete mode 100644 ansible/playbooks/ioc-restart.yml delete mode 100644 ansible/playbooks/ioc-start.yml delete mode 100644 ansible/tasks/ioc/ioc-install.yml delete mode 100644 ansible/tasks/ioc/ioc-restart.yml delete mode 100644 ansible/tasks/ioc/ioc-start.yml delete mode 100644 ansible/tasks/service/service-deploy-and-restart.yml delete mode 100644 ansible/tasks/utils/clone-repo.yml delete mode 100644 ansible/tasks/utils/get-ioc-port.yml delete mode 100644 ansible/tasks/utils/init-service-name.yml delete mode 100644 ansible/tasks/utils/load-substitutions.yml delete mode 100644 cli/src/core/ansible.py delete mode 100644 cli/src/ioc.py delete mode 100644 docs/developer/ansible.md create mode 100644 packages/agebd/src/agebd/names.py create mode 100644 packages/agebd/tests/test_names.py create mode 100644 scripts/ci/service_names.py create mode 100644 scripts/tests/test_service_names.py 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