diff --git a/.ansible-lint-ignore b/.ansible-lint-ignore index cd45a3daa..63f837ade 100644 --- a/.ansible-lint-ignore +++ b/.ansible-lint-ignore @@ -19,6 +19,8 @@ roles/collabora/defaults/main.yml jinja[spacing] roles/collect_rpmnew_rpmsave/defaults/main.yml jinja[spacing] roles/coturn/defaults/main.yml jinja[spacing] roles/crypto_policy/defaults/main.yml jinja[spacing] +roles/curaline/defaults/main.yml jinja[spacing] +roles/curaline/vars/main.yml jinja[spacing] roles/dnf_makecache/defaults/main.yml jinja[spacing] roles/dnf_versionlock/defaults/main.yml jinja[spacing] roles/docker/defaults/main.yml jinja[spacing] diff --git a/CHANGELOG.md b/CHANGELOG.md index a8f413564..1ea32a337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* **role:curaline, playbook:curaline**: Add a role and playbook that install and configure the curaLINE Client of Swisscom, which exchanges files with the curaLINE Document Routing service. * **role:gitlab_ce**: Behind a reverse proxy, GitLab can show and log the real client IP address instead of the address of the proxy, for example under "Current sign-in IP". Set `gitlab_ce__rb_nginx_real_ip_trusted_addresses` to the address of the proxy. * **role:apache_tomcat**: With `lfops__remove_rpmnew_rpmsave`, the role also removes the `.rpmnew` and `.rpmsave` files next to the Tomcat configuration files it deploys. * **role:apache_tomcat**: Add support for Tomcat 10.1 on RHEL 10. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index d6410f0cc..76a988669 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -32,6 +32,7 @@ Which Ansible role is proven to run on which OS? | core_dumps | x | x | x | x | x | x | x | x | | | coturn | | | x | x | (x) | | | | | | crypto_policy | | | x | x | x | | | | | +| curaline | x | x | x | x | x | x | x | x | x86_64 only | | dnf_makecache | | | x | x | x | | | | | | dnf_versionlock | | | x | x | (x) | | | | | | docker | | | x | (x) | (x) | | | | | diff --git a/extensions/molecule/curaline/hnet/converge.yml b/extensions/molecule/curaline/hnet/converge.yml new file mode 100644 index 000000000..543ddfa7a --- /dev/null +++ b/extensions/molecule/curaline/hnet/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge curaline playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.curaline' diff --git a/extensions/molecule/curaline/hnet/inventory/group_vars/systems_under_test.yml b/extensions/molecule/curaline/hnet/inventory/group_vars/systems_under_test.yml new file mode 100644 index 000000000..3fb5d5148 --- /dev/null +++ b/extensions/molecule/curaline/hnet/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,28 @@ +curaline__version: '6.2.1' + +# the credentials are not valid: the client keeps running and reports the denied login in its +# health endpoint, which is all this scenario needs to see that it reads its configuration. +curaline__client_id: 'linuxfabrik' +curaline__client_secret: 'linuxfabrik' + +# a non-default value, so verify.yml can see that the running client uses what was configured. +curaline__schedule_delay: 'PT15M' + +curaline__connectors__group_var: + - connector-id: 40000 + mode: 'test' + content-type: 'application/forumdatenaustausch+xml;charset=UTF-8' + source-folder: '/data/xml/outbox-test/XMLDatenforum' + target-folder: '/data/xml/inbox-test/XMLDatenforum' + source-archive-enabled: true + source-archive-folder: '/data/xml/archive-test' + # a connector that is set to absent must neither end up in the configuration nor get folders. + - connector-id: 40000 + mode: 'production' + source-folder: '/data/xml/outbox-absent/XMLDatenforum' + target-folder: '/data/xml/inbox-absent/XMLDatenforum' + state: 'absent' + +# take over the account and the data folders of the H-Net SecureClient that prepare.yml set up. +curaline__group: 'hnet' +curaline__user: 'hnet' diff --git a/extensions/molecule/curaline/hnet/inventory/hosts.yml b/extensions/molecule/curaline/hnet/inventory/hosts.yml new file mode 100644 index 000000000..0fa5a5c06 --- /dev/null +++ b/extensions/molecule/curaline/hnet/inventory/hosts.yml @@ -0,0 +1,21 @@ +# yamllint disable rule:empty-values + +# curaline targets 'lfops_curaline' (see playbooks/curaline.yml: hosts). +# +# All platforms of COMPATIBILITY.md are covered: the client is a self-contained download +# with its own Java runtime, so the role differs between the families only in the packages +# and in SELinux, which the RHEL family has enforcing. +lfops_curaline: + children: + systems_under_test: + +systems_under_test: + hosts: + debian12-vm: + debian13-vm: + rocky8-vm: + rocky9-vm: + rocky10-vm: + ubuntu2204-vm: + ubuntu2404-vm: + ubuntu2604-vm: diff --git a/extensions/molecule/curaline/hnet/molecule.yml b/extensions/molecule/curaline/hnet/molecule.yml new file mode 100644 index 000000000..c56bcd86f --- /dev/null +++ b/extensions/molecule/curaline/hnet/molecule.yml @@ -0,0 +1,11 @@ +# Molecule scenario marker for the 'hnet' sub-scenario, run with +# `molecule test --scenario-name curaline/hnet`. +# +# The prepare playbook is overridden so the host looks like one with an H-Net SecureClient +# installation before converge runs: the regular `hnet` account with its home in +# /home/hnet and the /data/xml tree owned by it, labelled `user_home_t` on the RHEL +# family. The scenario-local prepare.yml imports the shared vm-prepare.yml first, so the +# systems under test are still waited for and their facts still gathered. +provisioner: + playbooks: + prepare: '${MOLECULE_SCENARIO_DIRECTORY}/prepare.yml' diff --git a/extensions/molecule/curaline/hnet/prepare.yml b/extensions/molecule/curaline/hnet/prepare.yml new file mode 100644 index 000000000..7945915a7 --- /dev/null +++ b/extensions/molecule/curaline/hnet/prepare.yml @@ -0,0 +1,69 @@ +# Wait for the systems under test and gather their facts, as every other scenario does. +- name: 'Prepare VMs for Ansible' + ansible.builtin.import_playbook: '../../playbooks/vm-prepare.yml' + + +# Set the host up the way the Linuxfabrik installation guide of the H-Net SecureClient +# does, without the SecureClient itself: the role has to take over this account and these +# folders without changing them. +- name: 'Set up the account and the data folders of an H-Net SecureClient' + hosts: 'systems_under_test' + + tasks: + + - name: 'useradd --comment "User for H-Net Secure Client" hnet' + ansible.builtin.user: + name: 'hnet' + comment: 'User for H-Net Secure Client' + shell: '/bin/bash' + state: 'present' + + - name: 'mkdir -p /data/xml/{inbox,inbox-test,outbox,outbox-test,archive,archive-test} /var/hnet' + ansible.builtin.file: + path: '{{ item }}' + state: 'directory' + owner: 'hnet' + group: 'hnet' + mode: 0o755 + loop: + - '/data/xml' + - '/data/xml/archive' + - '/data/xml/archive-test' + - '/data/xml/inbox' + - '/data/xml/inbox-test' + - '/data/xml/outbox' + - '/data/xml/outbox-test' + - '/var/hnet' + + # an existing connector folder with a mode the role would not set, so verify.yml can + # see that the role leaves it alone. + - name: 'mkdir -m 0770 /data/xml/outbox-test/XMLDatenforum' + ansible.builtin.file: + path: '/data/xml/outbox-test/XMLDatenforum' + state: 'directory' + owner: 'hnet' + group: 'hnet' + mode: 0o770 + + - name: 'Label /data/xml as user_home_t' + when: + - 'ansible_facts["os_family"] == "RedHat"' + block: + + - name: 'dnf install policycoreutils-python-utils' + ansible.builtin.package: + name: 'policycoreutils-python-utils' + state: 'present' + retries: 3 + delay: 10 + + - name: 'semanage fcontext --add --type user_home_t "/data/xml(/.*)?"' + community.general.sefcontext: + target: '/data/xml(/.*)?' + setype: 'user_home_t' + state: 'present' + + - name: 'restorecon -Fvr /data/xml' + ansible.builtin.command: 'restorecon -Fvr /data/xml' + register: '__molecule__curaline_restorecon_result' + changed_when: '__molecule__curaline_restorecon_result["stdout"] | length > 0' diff --git a/extensions/molecule/curaline/hnet/verify.yml b/extensions/molecule/curaline/hnet/verify.yml new file mode 100644 index 000000000..677b469c0 --- /dev/null +++ b/extensions/molecule/curaline/hnet/verify.yml @@ -0,0 +1,64 @@ +# The client has to work under the `hnet` account exactly as under its own one, so the +# checks of the install scenario run first. +- name: 'Verify curaline' + ansible.builtin.import_playbook: '../install/verify.yml' + + +# What the takeover must not change is the account and the folders of the H-Net +# SecureClient, and the client must not write into /home/hnet, which the SecureClient +# lives in. +- name: 'Verify that the H-Net SecureClient installation is untouched' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'getent passwd hnet' + ansible.builtin.getent: + database: 'passwd' + key: 'hnet' + + # getent_passwd values: password, UID, GID, comment, home, shell + - name: 'Assert that the hnet account keeps its comment, home and shell' + ansible.builtin.assert: + that: + - 'ansible_facts["getent_passwd"]["hnet"][3] == "User for H-Net Secure Client"' + - 'ansible_facts["getent_passwd"]["hnet"][4] == "/home/hnet"' + - 'ansible_facts["getent_passwd"]["hnet"][5] == "/bin/bash"' + + - name: 'stat /data/xml/outbox-test/XMLDatenforum' + ansible.builtin.stat: + path: '/data/xml/outbox-test/XMLDatenforum' + register: '__molecule__curaline_outbox_result' + + - name: 'Assert that the existing connector folder keeps its mode' + ansible.builtin.assert: + that: + - '__molecule__curaline_outbox_result["stat"]["mode"] == "0770"' + - '__molecule__curaline_outbox_result["stat"]["pw_name"] == "hnet"' + + # the client gets its $HOME from the unit, so its `.cache` lands below + # /var/lib/swisscom-schweiz-ag/cdr-client instead. + - name: 'stat /home/hnet/.cache' + ansible.builtin.stat: + path: '/home/hnet/.cache' + register: '__molecule__curaline_home_cache_result' + + - name: 'Assert that the client did not write into the home of hnet' + ansible.builtin.assert: + that: 'not __molecule__curaline_home_cache_result["stat"]["exists"]' + + - name: 'Verify the SELinux context of the data folders' + when: + - 'inventory_hostname is match("rocky")' + block: + + - name: 'stat --format=%C /data/xml/inbox-test/XMLDatenforum' + ansible.builtin.command: 'stat --format=%C /data/xml/inbox-test/XMLDatenforum' + changed_when: false + register: '__molecule__curaline_inbox_result' + + # the role created this folder, the file context of the SecureClient setup has to + # apply to it as well. + - name: 'Assert that the folders the role created get the user_home_t type' + ansible.builtin.assert: + that: '":user_home_t:" in __molecule__curaline_inbox_result["stdout"]' diff --git a/extensions/molecule/curaline/install/converge.yml b/extensions/molecule/curaline/install/converge.yml new file mode 100644 index 000000000..543ddfa7a --- /dev/null +++ b/extensions/molecule/curaline/install/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge curaline playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.curaline' diff --git a/extensions/molecule/curaline/install/inventory/group_vars/systems_under_test.yml b/extensions/molecule/curaline/install/inventory/group_vars/systems_under_test.yml new file mode 100644 index 000000000..0516bb6d5 --- /dev/null +++ b/extensions/molecule/curaline/install/inventory/group_vars/systems_under_test.yml @@ -0,0 +1,24 @@ +curaline__version: '6.2.1' + +# the credentials are not valid: the client keeps running and reports the denied login in its +# health endpoint, which is all this scenario needs to see that it reads its configuration. +curaline__client_id: 'linuxfabrik' +curaline__client_secret: 'linuxfabrik' + +# a non-default value, so verify.yml can see that the running client uses what was configured. +curaline__schedule_delay: 'PT15M' + +curaline__connectors__group_var: + - connector-id: 40000 + mode: 'test' + content-type: 'application/forumdatenaustausch+xml;charset=UTF-8' + source-folder: '/data/xml/outbox-test/XMLDatenforum' + target-folder: '/data/xml/inbox-test/XMLDatenforum' + source-archive-enabled: true + source-archive-folder: '/data/xml/archive-test' + # a connector that is set to absent must neither end up in the configuration nor get folders. + - connector-id: 40000 + mode: 'production' + source-folder: '/data/xml/outbox-absent/XMLDatenforum' + target-folder: '/data/xml/inbox-absent/XMLDatenforum' + state: 'absent' diff --git a/extensions/molecule/curaline/install/inventory/hosts.yml b/extensions/molecule/curaline/install/inventory/hosts.yml new file mode 100644 index 000000000..0fa5a5c06 --- /dev/null +++ b/extensions/molecule/curaline/install/inventory/hosts.yml @@ -0,0 +1,21 @@ +# yamllint disable rule:empty-values + +# curaline targets 'lfops_curaline' (see playbooks/curaline.yml: hosts). +# +# All platforms of COMPATIBILITY.md are covered: the client is a self-contained download +# with its own Java runtime, so the role differs between the families only in the packages +# and in SELinux, which the RHEL family has enforcing. +lfops_curaline: + children: + systems_under_test: + +systems_under_test: + hosts: + debian12-vm: + debian13-vm: + rocky8-vm: + rocky9-vm: + rocky10-vm: + ubuntu2204-vm: + ubuntu2404-vm: + ubuntu2604-vm: diff --git a/extensions/molecule/curaline/install/molecule.yml b/extensions/molecule/curaline/install/molecule.yml new file mode 100644 index 000000000..0e5759258 --- /dev/null +++ b/extensions/molecule/curaline/install/molecule.yml @@ -0,0 +1,7 @@ +# Molecule scenario marker for the 'install' sub-scenario, run with +# `molecule test --scenario-name curaline/install`. +# +# The shared config.yml already provisions libvirt/KVM VMs, so this +# scenario needs no driver override: the role sets the SELinux file context that lets systemd +# execute the client from /usr/lib, which only a VM with an enforcing SELinux proves, and the +# Debian family needs systemd, which the Debian and Ubuntu container images do not ship. diff --git a/extensions/molecule/curaline/install/verify.yml b/extensions/molecule/curaline/install/verify.yml new file mode 100644 index 000000000..e74b35630 --- /dev/null +++ b/extensions/molecule/curaline/install/verify.yml @@ -0,0 +1,112 @@ +# What only the running system can confirm is that the client starts from the unpacked +# download under the configured account, that SELinux lets systemd execute it, and that it reads the +# configuration the role wrote: the client is asked for its health and its version, and it +# reports the values it parsed in its log. The configuration files themselves are not checked. +- name: 'Verify curaline' + hosts: 'systems_under_test' + gather_facts: false + tasks: + + - name: 'Gather the service facts' + ansible.builtin.service_facts: # yamllint disable-line rule:empty-values + + - name: 'Assert that swisscom-schweiz-ag-cdr-client.service is running and enabled' + ansible.builtin.assert: + that: + - 'ansible_facts["services"]["swisscom-schweiz-ag-cdr-client.service"]["state"] == "running"' + - 'ansible_facts["services"]["swisscom-schweiz-ag-cdr-client.service"]["status"] == "enabled"' + + - name: 'Wait for the management port of the client' + ansible.builtin.wait_for: + host: '127.0.0.1' + port: 8193 + timeout: 60 + + # the overall status is UP even though the credentials of this scenario are rejected, so the + # components are asserted: `config` is OK only if the client parsed the file without + # complaint. + - name: 'curl http://127.0.0.1:8193/actuator/health' + ansible.builtin.uri: + url: 'http://127.0.0.1:8193/actuator/health' + return_content: true + register: '__molecule__curaline_health_result' + + - name: 'Assert that the client accepted its configuration' + ansible.builtin.assert: + that: + - '__molecule__curaline_health_result["json"]["components"]["config"]["status"] == "OK"' + - '__molecule__curaline_health_result["json"]["components"]["fileSynchronization"]["status"] == "ENABLED"' + + - name: 'curl http://127.0.0.1:8193/actuator/info' + ansible.builtin.uri: + url: 'http://127.0.0.1:8193/actuator/info' + return_content: true + register: '__molecule__curaline_info_result' + + - name: 'Assert that the client is the installed version' + ansible.builtin.assert: + that: '__molecule__curaline_info_result["json"]["build"]["version"] == curaline__version' + + - name: 'systemctl show swisscom-schweiz-ag-cdr-client.service --property=MainPID --value' # noqa command-instead-of-module (read-only property query) + ansible.builtin.command: 'systemctl show swisscom-schweiz-ag-cdr-client.service --property=MainPID --value' + changed_when: false + register: '__molecule__curaline_mainpid_result' + + - name: 'stat /proc/{{ __molecule__curaline_mainpid_result["stdout"] }}' + ansible.builtin.stat: + path: '/proc/{{ __molecule__curaline_mainpid_result["stdout"] }}' + register: '__molecule__curaline_proc_result' + + # the hnet scenario imports this playbook and sets curaline__user, the install scenario + # relies on the role default. + - name: 'Assert that the client runs as {{ curaline__user | d("swisscom-schweiz-ag-cdr-client") }}' + ansible.builtin.assert: + that: '__molecule__curaline_proc_result["stat"]["pw_name"] == curaline__user | d("swisscom-schweiz-ag-cdr-client")' + + # the client logs the schedule delay and the folders it watches after it has parsed them. + - name: 'Wait for the client to log the polling of the source folder' + ansible.builtin.command: "grep --no-messages 'Polling source directory' /var/log/swisscom-schweiz-ag/cdr-client/cdr-client-service.log" + register: '__molecule__curaline_log_result' + changed_when: false + retries: 12 + delay: 5 + until: '__molecule__curaline_log_result["rc"] == 0' + + - name: 'Assert that the client uses the configured schedule delay and source folder' + ansible.builtin.assert: + that: + - '"every ''15m'' for connector" in __molecule__curaline_log_result["stdout"]' + - '"/data/xml/outbox-test/XMLDatenforum" in __molecule__curaline_log_result["stdout"]' + + - name: 'stat /data/xml/outbox-absent' + ansible.builtin.stat: + path: '/data/xml/outbox-absent' + register: '__molecule__curaline_absent_result' + + - name: 'Assert that the absent connector got no folders' + ansible.builtin.assert: + that: 'not __molecule__curaline_absent_result["stat"]["exists"]' + + # without the bin_t file context systemd cannot execute the client from /usr/lib, and the + # service would not run at all. a host that happens to run permissive would pass that + # assertion without proving anything, so the mode is asserted too. + - name: 'Verify the SELinux context of the client' + when: + - 'inventory_hostname is match("rocky")' + block: + + - name: 'getenforce' + ansible.builtin.command: 'getenforce' + changed_when: false + register: '__molecule__curaline_getenforce_result' + + - name: 'cat /proc/{{ __molecule__curaline_mainpid_result["stdout"] }}/attr/current' + ansible.builtin.command: 'cat /proc/{{ __molecule__curaline_mainpid_result["stdout"] }}/attr/current' + changed_when: false + register: '__molecule__curaline_context_result' + + - name: 'Assert that SELinux is enforcing and the client runs in unconfined_service_t' + ansible.builtin.assert: + that: + - '__molecule__curaline_getenforce_result["stdout"] == "Enforcing"' + - '"unconfined_service_t" in __molecule__curaline_context_result["stdout"]' diff --git a/playbooks/README.md b/playbooks/README.md index 205dfa261..391e732b5 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -209,6 +209,13 @@ Calls the following roles (in order): * [crypto_policy](https://github.com/Linuxfabrik/lfops/tree/main/roles/crypto_policy) +## curaline.yml + +Calls the following roles (in order): + +* [curaline](https://github.com/Linuxfabrik/lfops/tree/main/roles/curaline) + + ## dnf_makecache.yml Calls the following roles (in order): diff --git a/playbooks/all.yml b/playbooks/all.yml index 64a340dfe..cd9a56a93 100644 --- a/playbooks/all.yml +++ b/playbooks/all.yml @@ -21,6 +21,7 @@ - import_playbook: 'core_dumps.yml' - import_playbook: 'coturn.yml' - import_playbook: 'crypto_policy.yml' +- import_playbook: 'curaline.yml' - import_playbook: 'dnf_makecache.yml' - import_playbook: 'dnf_versionlock.yml' - import_playbook: 'docker.yml' diff --git a/playbooks/curaline.yml b/playbooks/curaline.yml new file mode 100644 index 000000000..98d8a08f4 --- /dev/null +++ b/playbooks/curaline.yml @@ -0,0 +1,36 @@ +- name: 'Playbook linuxfabrik.lfops.curaline' + hosts: + - 'lfops_curaline' + force_handlers: true + + pre_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-start.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'global-variables.yml' + tags: + - 'always' + + + roles: + + - role: 'linuxfabrik.lfops.curaline' + + + post_tasks: + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'log-end.yml' + tags: + - 'always' + + - ansible.builtin.import_role: + name: 'shared' + tasks_from: 'print-messages.yml' + tags: + - 'always' diff --git a/roles/curaline/README.md b/roles/curaline/README.md new file mode 100644 index 000000000..328dfb552 --- /dev/null +++ b/roles/curaline/README.md @@ -0,0 +1,349 @@ +# Ansible Role linuxfabrik.lfops.curaline + +This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom, which exchanges files with the curaLINE Document Routing (CDR) service of the Swiss healthcare system. It unpacks the client from the Swisscom download page, configures its connectors and runs it as the systemd service `swisscom-schweiz-ag-cdr-client.service`, either under a system account of its own or under an existing account such as `hnet` of an H-Net SecureClient installation. + + +*Available in the next LFOps release.* + + +## How the Role Behaves + +* The client is downloaded on the Ansible controller (`delegate_to: 'localhost'`) into `/tmp` and uploaded to the target, so targets without Internet access can still be provisioned. The controller needs outbound HTTPS access to `cdr.health.swisscom.ch`. The target needs it only for the client to do its work. +* The client comes with its own Java runtime and is only published for `linux-amd64`. The role aborts on other architectures. +* The installed version is recorded in `/usr/lib/swisscom-schweiz-ag/cdr-client/.ansible-version`. When it differs from `curaline__version`, the role removes `/usr/lib/swisscom-schweiz-ag/cdr-client`, unpacks the new version and restarts the service. The configuration lives in `/etc/swisscom-schweiz-ag/cdr-client` and survives the update. +* The service runs as `curaline__user` and `curaline__group`. If they do not exist, the role creates them as a system user and group. An existing user is taken over as it is: the role changes neither its UID, home, shell nor comment, so other software using the account keeps working. +* The installation directory belongs to `curaline__user`, as with the Debian package of the client. The role hands it over recursively when `curaline__user` or `curaline__group` change. +* The systemd unit is modelled on the one of the Debian package, with the same name, user and group names and directories, but with a static user instead of a dynamic one. The client gets `/var/lib/swisscom-schweiz-ag/cdr-client` as its `$HOME`, whatever the home of `curaline__user` is, and writes its log to `/var/log/swisscom-schweiz-ag/cdr-client`. The unit runs the client sandboxed: it may only write to its installation, configuration, state, cache and log directories and to the folders of its connectors. +* The folders of the connectors (`source-folder`, `target-folder`, `source-archive-folder`) and `curaline__local_folder` are created for `curaline__user` if they do not exist yet. Existing folders keep their owner, mode and content, so they must already be writable by `curaline__user`. Parent directories are created owned by `root`. The client creates the subfolders below them, such as `error`, itself. +* Files must be written to a source folder under a temporary name and renamed to `*.xml` when complete, and must be readable by `curaline__user`. The client uploads a file that is still being written in fragments, and retries an unreadable file forever as `*.upload`. +* The files in `/etc/swisscom-schweiz-ag/cdr-client` are re-rendered on every run (a timestamped backup is kept), so manual edits are overwritten. This includes the client secret in `application-customer.yaml`. +* With `curaline__renew_credential: true` the client renews the secret and rewrites `application-customer.yaml` itself. The next run of the role restores the old secret and renewal time from the inventory, so the client immediately tries to renew again with a secret that is no longer valid, and its login is denied. Keep it at `false` and renew the secret by hand. +* The role does not manage the firewall. The client only makes outgoing connections, and its management ports `8191` and `8193` listen on `127.0.0.1`. +* On the Red Hat family with SELinux, the role sets the `bin_t` file context on `/usr/lib/swisscom-schweiz-ag/cdr-client/bin`, so that systemd may execute the client from `/usr/lib`. +* The client downloads a document into `curaline__local_folder` and then moves it to the target folder, so the file keeps the SELinux label of `curaline__local_folder`. If a confined service reads the target folder, for example the `/data/xml` tree of an H-Net SecureClient installation labelled `user_home_t`, put `curaline__local_folder` below the same tree, such as `/data/xml/cdr-inflight`. + + +## Known Limitations + +* Only `x86_64` hosts are supported. +* Hosts with the Debian package of the client are not supported. Remove the package before running the role. +* The role does not configure the proxy of the client. +* The role manages one client per host. + + +## Requirements + +* Credentials for the curaLINE Document Routing service: the client ID and the client secret of the application registered for this client, and the connector ID(s) of the practice or institution. +* Outbound HTTPS access from the Ansible controller to `cdr.health.swisscom.ch`, and from the target to `cdr.health.swisscom.ch` and `login.microsoftonline.com`. + + +## Replacing the H-Net SecureClient + +To take over the data folders of an H-Net SecureClient installation (typically `/data/xml/{inbox,outbox,archive}` and their `-test` counterparts, owned by `hnet:hnet`), run the client under the same account: + +```yaml +curaline__group: 'hnet' +curaline__user: 'hnet' +``` + +The role leaves the `hnet` account, `/home/hnet`, `/var/hnet` and the SELinux setup of the SecureClient (`user_home_t` on `/data/xml`, the `HnetSecureService` policy module) untouched, and does not manage `hnet-securesvc.service`. + +**Warning:** Do not run both clients on the same folders. The SecureClient and the curaLINE Client would both pick up the files in the outbox, and documents could be sent twice or get lost. Stop and disable the SecureClient (`systemctl disable --now hnet-securesvc.service`) before the curaLINE Client is started for the first time, or deploy the role with `curaline__service_enabled: false` and `curaline__service_state: 'stopped'` until the switch-over. + + +## Post-Installation Steps + +* Check the connection to the service: `curl --silent http://127.0.0.1:8193/actuator/health` no longer reports `"authN": {"status": "DENIED"}` once the credentials are accepted. + + +## Tags + +`curaline` + +* Installs the required packages. +* Creates the user and group of the service if they do not exist, and the missing folders of the connectors. +* Installs the client. +* Deploys the configuration files and the systemd unit. +* Sets the SELinux file context of the client (Red Hat family with SELinux). +* Ensures the service is in the desired state. +* Triggers: swisscom-schweiz-ag-cdr-client.service restart. + +`curaline:configure` + +* Deploys the configuration files and the systemd unit. +* Sets the SELinux file context of the client (Red Hat family with SELinux). +* Triggers: swisscom-schweiz-ag-cdr-client.service restart. + +`curaline:state` + +* Manages the service state (start, stop, enable, disable). +* Triggers: none. + +`curaline:update` + +* Installs the client in the version of `curaline__version`, if a different version or none is installed, and hands the installation directory to `curaline__user`. +* Sets the SELinux file context of the client (Red Hat family with SELinux). +* Triggers: swisscom-schweiz-ag-cdr-client.service restart. + + +## Mandatory Role Variables + +`curaline__client_id` + +* The client ID of the application registered for this client. +* Type: String. + +`curaline__client_secret` + +* The client secret that belongs to the client ID. +* Type: String. + +`curaline__connectors__host_var` / `curaline__connectors__group_var` + +* The connectors of the client. At least one must be present. Items are identified by the combination of `connector-id` and `mode`, so the same connector can run in `test` and `production` mode at once. +* Type: List of dictionaries. +* Default: `[]` +* Subkeys: + + * `connector-id`: + + * Mandatory. The ID of the connector, assigned by Swisscom. + * Type: Number. + + * `mode`: + + * Mandatory. `production` or `test`. + * Type: String. + + * `content-type`: + + * Mandatory. The content type of the documents. + * Type: String. + + * `source-folder`: + + * Mandatory. The folder the client uploads documents from. + * Type: String. + + * `target-folder`: + + * Mandatory. The folder the client puts downloaded documents into. + * Type: String. + + * `source-archive-enabled`: + + * Optional. Whether the client moves uploaded documents to `source-archive-folder`. + * Type: Bool. + + * `source-archive-folder`: + + * Optional. The folder for uploaded documents. + * Type: String. + + * `state`: + + * Optional. `present` or `absent`. A connector set to `absent` is removed from the configuration, and its folders are not created. + * Type: String. + * Default: `'present'` + + * Any other key the client knows is written to the configuration as it is. + +`curaline__version` + +* The version of the curaLINE Client to install. +* Type: String. + +Example: +```yaml +# mandatory +curaline__client_id: 'linuxfabrik' +curaline__client_secret: 'linuxfabrik' +curaline__connectors__host_var: + - connector-id: 40000 + mode: 'test' + content-type: 'application/forumdatenaustausch+xml;charset=UTF-8' + source-folder: '/data/xml/outbox-test/XMLDatenforum' + target-folder: '/data/xml/inbox-test/XMLDatenforum' + source-archive-enabled: true + source-archive-folder: '/data/xml/archive-test' +curaline__version: '6.2.1' +``` + + +## Optional Role Variables + +`curaline__cdr_api_base_path` + +* Base path of the curaLINE document API. +* Type: String. +* Default: `'api/documents'` + +`curaline__cdr_api_host` + +* Host name of the curaLINE API. +* Type: String. +* Default: `'cdr.health.swisscom.ch'` + +`curaline__cdr_api_port` + +* Port of the curaLINE document API. +* Type: Number. +* Default: `443` + +`curaline__cdr_api_scheme` + +* Scheme of the curaLINE document API. One of `http`, `https`. +* Type: String. +* Default: `'https'` + +`curaline__credential_api_base_path` + +* Base path of the curaLINE credential API. +* Type: String. +* Default: `'api/client-credentials'` + +`curaline__credential_api_host` + +* Host name of the curaLINE credential API. +* Type: String. +* Default: `{{ curaline__cdr_api_host }}` + +`curaline__credential_api_port` + +* Port of the curaLINE credential API. +* Type: Number. +* Default: `{{ curaline__cdr_api_port }}` + +`curaline__credential_api_scheme` + +* Scheme of the curaLINE credential API. One of `http`, `https`. +* Type: String. +* Default: `{{ curaline__cdr_api_scheme }}` + +`curaline__file_busy_test_strategy` + +* How the client decides that a file in a source folder is completely written. `file_size_changed` samples the file size, `never_busy` takes every file as finished, `always_busy` never uploads. +* Type: String. One of `always_busy`, `file_size_changed`, `never_busy`. +* Default: `'file_size_changed'` + +`curaline__file_synchronization_enabled` + +* Whether the client uploads and downloads files. +* Type: Bool. +* Default: `true` + +`curaline__group` + +* Group of the service. Created as a system group if it does not exist. Set it to `'hnet'` to take over an H-Net SecureClient installation, see "Replacing the H-Net SecureClient". +* Type: String. +* Default: `'swisscom-schweiz-ag-cdr-client'` + +`curaline__idp_endpoint` + +* The OAuth2 token endpoint of the identity provider. +* Type: String. +* Default: `'https://login.microsoftonline.com/{{ curaline__tenant_id }}/oauth2/v2.0/token'` + +`curaline__last_credential_renewal_time` + +* When the client credential was last renewed. A time in the past makes the client renew it on start, if `curaline__renew_credential` is enabled. +* Type: String. +* Default: `'1970-01-01T00:00:00Z'` + +`curaline__local_folder` + +* Folder for temporary downloads. +* Type: String. +* Default: `'/data/cdr-inflight'` +* Deviates from the upstream default `${java.io.tmpdir}`: the unit runs the client with `PrivateTmp=yes`, so its `/tmp` is private to the service and is emptied on every stop and restart, which would lose the documents that are being downloaded. + +`curaline__renew_credential` + +* Whether the client renews its client secret on its own, see "How the Role Behaves". +* Type: Bool. +* Default: `false` +* Deviates from the upstream default `true`: with it the client rewrites `application-customer.yaml` on its own, which the next run of the role would undo. + +`curaline__schedule_delay` + +* Interval between two upload and download runs, as an ISO 8601 duration. +* Type: String. +* Default: `'PT10M'` + +`curaline__scope` + +* The OAuth2 scope of the token request. +* Type: String. +* Default: `'https://identity.health.swisscom.ch/CdrApi/.default'` + +`curaline__service_enabled` + +* Enables or disables the service, analogous to `systemctl enable/disable --now`. +* Type: Bool. +* Default: `true` + +`curaline__service_state` + +* Changes the state of the service, analogous to `systemctl start/stop/restart`. +* Type: String. One of `restarted`, `started`, `stopped`. +* Default: `'started'` + +`curaline__tenant_id` + +* The Entra ID tenant that issues the tokens. The default is the tenant of Swisscom, which the client ships with. +* Type: String. +* Default: `'70b434db-eccb-4280-95dc-1e59220aca55'` + +`curaline__user` + +* User of the service. Created as a system user without a login shell if it does not exist. An existing user is not modified. Set it to `'hnet'` to take over an H-Net SecureClient installation, see "Replacing the H-Net SecureClient". +* Type: String. +* Default: `'swisscom-schweiz-ag-cdr-client'` + +Example: +```yaml +# optional +curaline__cdr_api_base_path: 'api/documents' +curaline__cdr_api_host: 'cdr.health.swisscom.ch' +curaline__cdr_api_port: 443 +curaline__cdr_api_scheme: 'https' +curaline__credential_api_base_path: 'api/client-credentials' +curaline__file_busy_test_strategy: 'file_size_changed' +curaline__file_synchronization_enabled: true +curaline__group: 'hnet' +curaline__local_folder: '/data/cdr-inflight' +curaline__renew_credential: false +curaline__schedule_delay: 'PT10M' +curaline__service_enabled: true +curaline__service_state: 'started' +curaline__user: 'hnet' +``` + + +## Troubleshooting + +**`curl http://127.0.0.1:8193/actuator/health` reports `"authN": {"status": "DENIED"}`** + +* The identity provider rejects the credentials. Check `curaline__client_id`, `curaline__client_secret` and `curaline__tenant_id`. The client keeps running and retries, the reason is in `/var/log/swisscom-schweiz-ag/cdr-client/cdr-client-service.log`. + +**The service fails with `status=226/NAMESPACE`** + +* A path in `ReadWritePaths=` of the unit does not exist, typically a folder of a connector that was removed by hand, or one on a network share that is not mounted. Run the role again to recreate missing folders, or mount the share before the service starts. + +**The run aborts with `curaline__version must look like "6.2.1"`** + +* Set `curaline__version` to a release listed on the curaLINE download page. + +**The run aborts with `At least one connector must be present ...` or `Connector ... must set content-type, source-folder and target-folder`** + +* Give the host at least one connector in `curaline__connectors__host_var` or `curaline__connectors__group_var`, with all mandatory subkeys set. + + +## License + +[The Unlicense](https://unlicense.org/) + + +## Author Information + +[Linuxfabrik GmbH, Zurich](https://www.linuxfabrik.ch) diff --git a/roles/curaline/defaults/main.yml b/roles/curaline/defaults/main.yml new file mode 100644 index 000000000..ae1884cfb --- /dev/null +++ b/roles/curaline/defaults/main.yml @@ -0,0 +1,39 @@ +curaline__cdr_api_base_path: 'api/documents' +curaline__cdr_api_host: 'cdr.health.swisscom.ch' +curaline__cdr_api_port: 443 +curaline__cdr_api_scheme: 'https' + +# list of dictionaries, merged on the composite key connector-id + mode, so the same connector +# can run in test and in production mode at once. +curaline__connectors__combined_var: '{{ ( + curaline__connectors__role_var + + curaline__connectors__dependent_var + + curaline__connectors__group_var + + curaline__connectors__host_var + ) | linuxfabrik.lfops.combine_lod(unique_key=["connector-id", "mode"]) + }}' +curaline__connectors__dependent_var: [] +curaline__connectors__group_var: [] +curaline__connectors__host_var: [] +curaline__connectors__role_var: [] + +curaline__credential_api_base_path: 'api/client-credentials' +curaline__credential_api_host: '{{ curaline__cdr_api_host }}' +curaline__credential_api_port: '{{ curaline__cdr_api_port }}' +curaline__credential_api_scheme: '{{ curaline__cdr_api_scheme }}' +curaline__file_busy_test_strategy: 'file_size_changed' +curaline__file_synchronization_enabled: true +curaline__group: 'swisscom-schweiz-ag-cdr-client' +curaline__idp_endpoint: 'https://login.microsoftonline.com/{{ curaline__tenant_id }}/oauth2/v2.0/token' +# a time in the past makes the client renew its credential immediately on start, if +# curaline__renew_credential is enabled +curaline__last_credential_renewal_time: '1970-01-01T00:00:00Z' +curaline__local_folder: '/data/cdr-inflight' # upstream default: ${java.io.tmpdir} +curaline__renew_credential: false # upstream default: true +curaline__schedule_delay: 'PT10M' +curaline__scope: 'https://identity.health.swisscom.ch/CdrApi/.default' +curaline__service_enabled: true +curaline__service_state: 'started' +# the Entra ID tenant of Swisscom, which the client ships with +curaline__tenant_id: '70b434db-eccb-4280-95dc-1e59220aca55' +curaline__user: 'swisscom-schweiz-ag-cdr-client' diff --git a/roles/curaline/handlers/main.yml b/roles/curaline/handlers/main.yml new file mode 100644 index 000000000..9ffcd2995 --- /dev/null +++ b/roles/curaline/handlers/main.yml @@ -0,0 +1,12 @@ +# skip the restart if the operator deferred restarts LFOps-wide, if the service was just started +# (redundant), or if the user wants it stopped. `is not defined` covers a run that skips the state +# block (`--skip-tags curaline:state`). this guard is only sound because the state task runs after +# every task that notifies this handler. +- name: 'curaline: restart swisscom-schweiz-ag-cdr-client' + ansible.builtin.service: + name: 'swisscom-schweiz-ag-cdr-client.service' + state: 'restarted' + when: + - 'not (lfops__skip_restart_handlers | d(false) | bool)' + - '__curaline__service_state_result is not defined or __curaline__service_state_result is not changed' + - 'curaline__service_state != "stopped"' diff --git a/roles/curaline/meta/argument_specs.yml b/roles/curaline/meta/argument_specs.yml new file mode 100644 index 000000000..0d4bce7f2 --- /dev/null +++ b/roles/curaline/meta/argument_specs.yml @@ -0,0 +1,176 @@ +argument_specs: + main: + options: + + curaline__cdr_api_base_path: + type: 'str' + required: false + default: 'api/documents' + description: 'Base path of the curaLINE document API.' + + curaline__cdr_api_host: + type: 'str' + required: false + default: 'cdr.health.swisscom.ch' + description: 'Host name of the curaLINE API.' + + curaline__cdr_api_port: + type: 'int' + required: false + default: 443 + description: 'Port of the curaLINE document API.' + + curaline__cdr_api_scheme: + type: 'str' + required: false + default: 'https' + choices: + - 'http' + - 'https' + description: 'Scheme of the curaLINE document API.' + + curaline__client_id: + type: 'str' + required: true + description: 'The client ID of the application registered for this client.' + + curaline__client_secret: + type: 'str' + required: true + no_log: true + description: 'The client secret that belongs to the client ID.' + + curaline__connectors__dependent_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Connectors. Dependent-role injection.' + + curaline__connectors__group_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Connectors. Group-level override.' + + curaline__connectors__host_var: + type: 'list' + elements: 'dict' + required: false + default: [] + description: 'Connectors. Host-level override.' + + curaline__credential_api_base_path: + type: 'str' + required: false + default: 'api/client-credentials' + description: 'Base path of the curaLINE credential API.' + + curaline__credential_api_host: + type: 'str' + required: false + description: 'Host name of the curaLINE credential API. Defaults to curaline__cdr_api_host.' + + curaline__credential_api_port: + type: 'int' + required: false + description: 'Port of the curaLINE credential API. Defaults to curaline__cdr_api_port.' + + curaline__credential_api_scheme: + type: 'str' + required: false + choices: + - 'http' + - 'https' + description: 'Scheme of the curaLINE credential API. Defaults to curaline__cdr_api_scheme.' + + curaline__file_busy_test_strategy: + type: 'str' + required: false + default: 'file_size_changed' + choices: + - 'always_busy' + - 'file_size_changed' + - 'never_busy' + description: 'How the client decides that a file in a source folder is completely written.' + + curaline__file_synchronization_enabled: + type: 'bool' + required: false + default: true + description: 'Whether the client uploads and downloads files.' + + curaline__group: + type: 'str' + required: false + default: 'swisscom-schweiz-ag-cdr-client' + description: 'Group of the service, created as a system group if it does not exist.' + + curaline__idp_endpoint: + type: 'str' + required: false + description: 'The OAuth2 token endpoint of the identity provider. Defaults to the one of curaline__tenant_id.' + + curaline__last_credential_renewal_time: + type: 'str' + required: false + default: '1970-01-01T00:00:00Z' + description: 'When the client credential was last renewed.' + + curaline__local_folder: + type: 'str' + required: false + default: '/data/cdr-inflight' + description: 'Folder for temporary downloads.' + + curaline__renew_credential: + type: 'bool' + required: false + default: false + description: 'Whether the client renews its client secret on its own.' + + curaline__schedule_delay: + type: 'str' + required: false + default: 'PT10M' + description: 'Interval between two upload and download runs, as an ISO 8601 duration.' + + curaline__scope: + type: 'str' + required: false + default: 'https://identity.health.swisscom.ch/CdrApi/.default' + description: 'The OAuth2 scope of the token request.' + + curaline__service_enabled: + type: 'bool' + required: false + default: true + description: 'Enables or disables the service.' + + curaline__service_state: + type: 'str' + required: false + default: 'started' + choices: + - 'restarted' + - 'started' + - 'stopped' + description: 'Changes the state of the service.' + + curaline__tenant_id: + type: 'str' + required: false + default: '70b434db-eccb-4280-95dc-1e59220aca55' + description: 'The Entra ID tenant that issues the tokens.' + + curaline__user: + type: 'str' + required: false + default: 'swisscom-schweiz-ag-cdr-client' + description: 'User of the service, created as a system user if it does not exist. An existing user is not modified.' + + curaline__version: + type: 'str' + required: true + description: 'The version of the curaLINE client to install.' diff --git a/roles/curaline/tasks/main.yml b/roles/curaline/tasks/main.yml new file mode 100644 index 000000000..16925aa5d --- /dev/null +++ b/roles/curaline/tasks/main.yml @@ -0,0 +1,313 @@ +- block: + + - name: 'Set platform/version specific variables' + ansible.builtin.import_role: + name: 'shared' + tasks_from: 'platform-variables.yml' + + tags: + - 'always' + + +- block: + + # simple checks (required, type, choices) live in meta/argument_specs.yml. a connector + # without `connector-id` or `mode` is already rejected by `combine_lod`, which names the + # item. + - name: 'Check variable constraints' + ansible.builtin.assert: + that: + - 'curaline__version is regex("^[0-9]+\.[0-9]+\.[0-9]+$")' + quiet: true + fail_msg: 'curaline__version must look like "6.2.1", got "{{ curaline__version }}".' + + - name: 'Check that at least one connector is present' + ansible.builtin.assert: + that: + - '__curaline__connectors_present | length > 0' + quiet: true + fail_msg: 'At least one connector must be present in curaline__connectors__host_var or curaline__connectors__group_var.' + + - name: 'Check that every connector sets content-type, source-folder and target-folder' + ansible.builtin.assert: + that: + - '"content-type" in item' + - '"source-folder" in item' + - '"target-folder" in item' + quiet: true + fail_msg: 'Connector {{ item["connector-id"] }} ({{ item["mode"] }}) must set content-type, source-folder and target-folder.' + loop: '{{ __curaline__connectors_present }}' + loop_control: + label: '{{ item["connector-id"] }} ({{ item["mode"] }})' + + - name: 'Check that the host is x86_64' + ansible.builtin.assert: + that: + - 'ansible_facts["architecture"] == "x86_64"' + quiet: true + fail_msg: 'The curaLINE client is only published for linux-amd64, this host is {{ ansible_facts["architecture"] }}.' + + tags: + - 'always' + + +- block: + + - name: 'Install required packages' + ansible.builtin.package: + name: '{{ __curaline__required_packages }}' + state: 'present' + retries: 3 # ride out a brief outage of the package repository + delay: 10 + + # an existing group keeps its GID, `ansible.builtin.group` only sets `system` on creation. + - name: 'groupadd --system {{ curaline__group }}' + ansible.builtin.group: + name: '{{ curaline__group }}' + state: 'present' + system: true + + # an existing account, such as the `hnet` account of an H-Net SecureClient installation, is + # taken over as it is. `ansible.builtin.user` would rewrite its home, shell and comment and + # break the other software using it. + - name: 'getent passwd {{ curaline__user }}' + ansible.builtin.command: 'getent passwd {{ curaline__user }}' + register: '__curaline__getent_passwd_result' + changed_when: false + failed_when: '__curaline__getent_passwd_result["rc"] not in [0, 2]' + check_mode: false + + - name: 'useradd --system --gid {{ curaline__group }} --home-dir {{ __curaline__state_dir }} --no-create-home --shell /usr/sbin/nologin {{ curaline__user }}' + ansible.builtin.user: + name: '{{ curaline__user }}' + comment: 'curaLINE Service Account' + group: '{{ curaline__group }}' + home: '{{ __curaline__state_dir }}' + create_home: false + shell: '/usr/sbin/nologin' + system: true + state: 'present' + when: '__curaline__getent_passwd_result["rc"] == 2' + + - name: 'mkdir -p {{ __curaline__config_dir }}' + ansible.builtin.file: + path: '{{ __curaline__config_dir }}' + state: 'directory' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o750 + + - name: 'stat {{ __curaline__folders | join(" ") }}' + ansible.builtin.stat: + path: '{{ item }}' + loop: '{{ __curaline__folders }}' + register: '__curaline__folders_stat_result' + + # only missing folders are created. existing ones, such as the `/data/xml` tree of an H-Net + # SecureClient installation, keep their owner and mode, and the files in them belong to + # whatever delivers or collects them. + - name: 'mkdir -p {{ __curaline__folders | join(" ") }}' + ansible.builtin.file: + path: '{{ item["item"] }}' + state: 'directory' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o755 + loop: '{{ __curaline__folders_stat_result["results"] | rejectattr("stat.exists") | list }}' + loop_control: + label: '{{ item["item"] }}' + + tags: + - 'curaline' + + +- block: + + - name: 'mkdir -p {{ __curaline__install_dir }}' + ansible.builtin.file: + path: '{{ __curaline__install_dir }}' + state: 'directory' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o755 + + - name: 'cat {{ __curaline__install_dir }}/.ansible-version' + ansible.builtin.command: 'cat {{ __curaline__install_dir }}/.ansible-version' + register: '__curaline__installed_version_result' + changed_when: false + failed_when: false + check_mode: false + + # the tarball ships its own Java runtime. the whole directory belongs to the service account, + # as the Debian package of the client does it on every start of the service. + - name: 'Install cdr-client {{ curaline__version }}' + when: + - '__curaline__installed_version_result["stdout"] | trim != curaline__version | string' + block: + + # download on the controller and let `unarchive` upload it, so that targets without + # internet access can be provisioned. see "How the Role Behaves" in the README. + - name: 'curl --output /tmp/ansible.cdr-client-{{ curaline__version }}-linux-amd64.tar.gz https://cdr.health.swisscom.ch/share/downloads/cdr-client-{{ curaline__version }}-linux-amd64.tar.gz' # noqa risky-file-permissions (temporary file) + ansible.builtin.get_url: + url: 'https://cdr.health.swisscom.ch/share/downloads/cdr-client-{{ curaline__version }}-linux-amd64.tar.gz' + dest: '/tmp/ansible.cdr-client-{{ curaline__version }}-linux-amd64.tar.gz' + mode: 0o644 + retries: 3 # ride out a brief outage of the download source + delay: 10 + delegate_to: 'localhost' + become: false + vars: + ansible_become: false # noqa var-naming[pattern] + changed_when: false # not an actual change on the target + check_mode: false # run task even if `--check` is specified + + # a stale file of the previous version (a renamed jar, for example) would end up on the + # class path, so start from an empty directory. + - name: 'rm -rf {{ __curaline__install_dir }}' + ansible.builtin.file: + path: '{{ __curaline__install_dir }}' + state: 'absent' + + - name: 'mkdir {{ __curaline__install_dir }}' + ansible.builtin.file: + path: '{{ __curaline__install_dir }}' + state: 'directory' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o755 + + - name: 'tar --extract --gzip --strip-components=1 --directory {{ __curaline__install_dir }}' + ansible.builtin.unarchive: + src: '/tmp/ansible.cdr-client-{{ curaline__version }}-linux-amd64.tar.gz' + dest: '{{ __curaline__install_dir }}' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + extra_opts: + - '--strip-components=1' + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' + + # written last: an aborted installation is repeated by the next run. a state marker is not + # a config file, so `copy` is fine here. + - name: 'echo {{ curaline__version }} > {{ __curaline__install_dir }}/.ansible-version' + ansible.builtin.copy: + dest: '{{ __curaline__install_dir }}/.ansible-version' + content: '{{ curaline__version }}' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o644 + + # hands an existing installation over after a change of `curaline__user` or `curaline__group`. + - name: 'chown -R --changes {{ curaline__user }}:{{ curaline__group }} {{ __curaline__install_dir }}' + ansible.builtin.command: 'chown --recursive --no-dereference --changes {{ curaline__user }}:{{ curaline__group }} {{ __curaline__install_dir }}' + register: '__curaline__chown_result' + changed_when: '__curaline__chown_result["stdout"] | length > 0' + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' + + tags: + - 'curaline' + - 'curaline:update' + + +- block: + + - name: 'Deploy {{ __curaline__config_dir }}/application-customer.yaml' + ansible.builtin.template: + backup: true + src: 'etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2' + dest: '{{ __curaline__config_dir }}/application-customer.yaml' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o640 # contains the client secret + diff: false # keep the client secret out of `--diff` output + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' + + - name: 'Deploy {{ __curaline__config_dir }}/logback-service.xml' + ansible.builtin.template: + backup: true + src: 'etc/swisscom-schweiz-ag/cdr-client/logback-service.xml.j2' + dest: '{{ __curaline__config_dir }}/logback-service.xml' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' + mode: 0o640 + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' + + # the unit is templated whole, not as a drop-in: the role does not install the Debian + # package, so there is no unit to override. it is modelled on the one of the package, but + # with a static account instead of a dynamic user, since the account owns the installation + # and data folders. + - name: 'Deploy /etc/systemd/system/swisscom-schweiz-ag-cdr-client.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2' + dest: '/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__curaline__unit_result' + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' + + - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway + ansible.builtin.systemd: + daemon_reload: true + when: '__curaline__unit_result is changed' + + tags: + - 'curaline' + - 'curaline:configure' + + +# its own block, because an update unpacks new files into bin/ that need the file context as well. +- block: + + # systemd starts the client from /usr/lib, where it needs the bin_t type to be allowed to + # execute it. + - name: 'semanage fcontext --add --type bin_t "{{ __curaline__install_dir }}/bin(/.*)?"' + community.general.sefcontext: + target: '{{ __curaline__install_dir }}/bin(/.*)?' + setype: 'bin_t' + state: 'present' + + - name: 'restorecon -RFv {{ __curaline__install_dir }}/bin' + ansible.builtin.command: 'restorecon -RFv {{ __curaline__install_dir }}/bin' + register: '__curaline__restorecon_result' + changed_when: '__curaline__restorecon_result["stdout"] | length > 0' + + when: + - 'ansible_facts["os_family"] == "RedHat"' + - 'ansible_facts["selinux"]["status"] != "disabled"' + + tags: + - 'curaline' + - 'curaline:configure' + - 'curaline:update' + + +- block: + + # the state task runs after every task that notifies the restart handler, see handlers/main.yml. + - name: 'systemctl {{ curaline__service_enabled | bool | ternary("enable", "disable") }} swisscom-schweiz-ag-cdr-client.service' + ansible.builtin.service: + name: 'swisscom-schweiz-ag-cdr-client.service' + enabled: '{{ curaline__service_enabled | bool }}' + + - name: 'systemctl {{ curaline__service_state | regex_replace("p?ed$", "") }} swisscom-schweiz-ag-cdr-client.service' + ansible.builtin.service: + name: 'swisscom-schweiz-ag-cdr-client.service' + state: '{{ curaline__service_state }}' + register: '__curaline__service_state_result' + + tags: + - 'curaline' + - 'curaline:state' + + +- block: + + - name: 'Flush handlers so that the restart happens before other roles continue' + ansible.builtin.meta: 'flush_handlers' + + tags: + - 'curaline' + - 'curaline:configure' + - 'curaline:state' + - 'curaline:update' diff --git a/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2 b/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2 new file mode 100644 index 000000000..5e0b45b5d --- /dev/null +++ b/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2 @@ -0,0 +1,36 @@ +# {{ ansible_managed }} +# 2026102001 +version: "1.2" +client: + file-synchronization-enabled: {{ curaline__file_synchronization_enabled | bool | lower }} + local-folder: {{ curaline__local_folder | to_json }} + proxy-config: + url: + username: + password: + idp-credentials: + tenant-id: {{ curaline__tenant_id | to_json }} + client-id: {{ curaline__client_id | to_json }} + client-secret: {{ curaline__client_secret | to_json }} + scope: {{ curaline__scope | to_json }} + renew-credential: {{ curaline__renew_credential | bool | lower }} + last-credential-renewal-time: {{ curaline__last_credential_renewal_time | to_json }} + idp-endpoint: {{ curaline__idp_endpoint | to_json }} + schedule-delay: {{ curaline__schedule_delay | to_json }} + file-busy-test-strategy: {{ curaline__file_busy_test_strategy | to_json }} + cdr-api: + scheme: {{ curaline__cdr_api_scheme | to_json }} + host: {{ curaline__cdr_api_host | to_json }} + port: {{ curaline__cdr_api_port }} + base-path: {{ curaline__cdr_api_base_path | to_json }} + credential-api: + scheme: {{ curaline__credential_api_scheme | to_json }} + port: {{ curaline__credential_api_port }} + host: {{ curaline__credential_api_host | to_json }} + base-path: {{ curaline__credential_api_base_path | to_json }} + customer: +{% for connector in __curaline__connectors_present %} +{% for key, value in connector.items() if key != 'state' %} + {{ '-' if loop.first else ' ' }} {{ key }}: {{ value | to_json }} +{% endfor %} +{% endfor %} diff --git a/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/logback-service.xml.j2 b/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/logback-service.xml.j2 new file mode 100644 index 000000000..3e3b04766 --- /dev/null +++ b/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/logback-service.xml.j2 @@ -0,0 +1,38 @@ + + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + ${LOG_DIR}/cdr-client-service.log + + ${LOG_DIR}/cdr-client-service-%d{yyyy-MM-dd}.%i.log.gz + 100MB + 15 + 1GB + + + %d{ISO8601} [%thread] %-5level %logger{36} -%kvp- %msg%n + + + + + + + + + + + + + diff --git a/roles/curaline/templates/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 b/roles/curaline/templates/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 new file mode 100644 index 000000000..13d740cbc --- /dev/null +++ b/roles/curaline/templates/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 @@ -0,0 +1,39 @@ +# {{ ansible_managed }} +# 2026101602 + +# Based on the unit of the Debian package of the client, but with a static user instead of `DynamicUser=yes`, so that the client can run under an existing account such as `hnet`. The sandboxing that `DynamicUser=yes` implies is set explicitly. + +[Unit] +After=network-online.target +Description=curaLINE Client +Wants=network-online.target + +[Service] +CacheDirectory=swisscom-schweiz-ag/cdr-client +ConfigurationDirectory=swisscom-schweiz-ag/cdr-client +ConfigurationDirectoryMode=0750 +# independent of the home of the user, so the client never writes into an existing home such as /home/hnet +Environment=HOME={{ __curaline__state_dir }} +ExecStart={{ __curaline__install_dir }}/bin/cdr-client-service +Group={{ curaline__group }} +LogsDirectory=swisscom-schweiz-ag/cdr-client +LogsDirectoryMode=0750 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectHome=read-only +ProtectSystem=strict +# the configuration directory is writable for `curaline__renew_credential` +ReadWritePaths="{{ ([__curaline__install_dir, __curaline__config_dir] + __curaline__folders) | join('" "') }}" +Restart=always +RestartSec=10s +RestrictSUIDSGID=yes +StateDirectory=swisscom-schweiz-ag/cdr-client +# the JVM exits with 143 on SIGTERM +SuccessExitStatus=143 +SyslogIdentifier=cdr-client +Type=exec +User={{ curaline__user }} +WorkingDirectory={{ __curaline__install_dir }} + +[Install] +WantedBy=multi-user.target diff --git a/roles/curaline/vars/Debian.yml b/roles/curaline/vars/Debian.yml new file mode 100644 index 000000000..919da92e6 --- /dev/null +++ b/roles/curaline/vars/Debian.yml @@ -0,0 +1,2 @@ +__curaline__required_packages: + - 'tar' diff --git a/roles/curaline/vars/RedHat.yml b/roles/curaline/vars/RedHat.yml new file mode 100644 index 000000000..831ebd17e --- /dev/null +++ b/roles/curaline/vars/RedHat.yml @@ -0,0 +1,3 @@ +__curaline__required_packages: + - 'policycoreutils-python-utils' # semanage + - 'tar' diff --git a/roles/curaline/vars/Ubuntu.yml b/roles/curaline/vars/Ubuntu.yml new file mode 100644 index 000000000..919da92e6 --- /dev/null +++ b/roles/curaline/vars/Ubuntu.yml @@ -0,0 +1,2 @@ +__curaline__required_packages: + - 'tar' diff --git a/roles/curaline/vars/main.yml b/roles/curaline/vars/main.yml new file mode 100644 index 000000000..dff30d953 --- /dev/null +++ b/roles/curaline/vars/main.yml @@ -0,0 +1,22 @@ +__curaline__config_dir: '/etc/swisscom-schweiz-ag/cdr-client' +__curaline__install_dir: '/usr/lib/swisscom-schweiz-ag/cdr-client' +# created by systemd (`StateDirectory=`) and set as $HOME of the client, see the unit +__curaline__state_dir: '/var/lib/swisscom-schweiz-ag/cdr-client' + +# the connectors that are not removed. the ones without a `state` come first, since `rejectattr` +# cannot test an attribute that is not there. +__curaline__connectors_present: '{{ + (curaline__connectors__combined_var | selectattr("state", "undefined") | list) + + (curaline__connectors__combined_var | selectattr("state", "defined") | rejectattr("state", "eq", "absent") | list) + }}' + +# every folder the client reads from or writes to: the temporary download folder plus the +# source, target and archive folders of the present connectors. The client creates the +# subfolders below them (such as `error`) itself. +__curaline__folders: '{{ ( + [curaline__local_folder] + + (__curaline__connectors_present | selectattr("source-folder", "defined") | map(attribute="source-folder") | list) + + (__curaline__connectors_present | selectattr("target-folder", "defined") | map(attribute="target-folder") | list) + + (__curaline__connectors_present | selectattr("source-archive-folder", "defined") | map(attribute="source-archive-folder") | list) + ) | unique | sort + }}'