From 0711a1378a1a1b053ce56779df6db6428fa0b942 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 15 Oct 2026 11:36:37 +0200 Subject: [PATCH 1/9] feat(roles/curaline): add role and playbook Install and configure the curaLINE Client of Swisscom as cdr-client.service. The client is downloaded on the controller, runs as an own system account, and its connectors are a combined list variable that also drives the folders the role creates. --- .ansible-lint-ignore | 2 + CHANGELOG.md | 1 + COMPATIBILITY.md | 1 + playbooks/README.md | 7 + playbooks/all.yml | 1 + playbooks/curaline.yml | 36 ++ roles/curaline/README.md | 307 ++++++++++++++++++ roles/curaline/defaults/main.yml | 37 +++ roles/curaline/handlers/main.yml | 12 + roles/curaline/meta/argument_specs.yml | 164 ++++++++++ roles/curaline/tasks/main.yml | 260 +++++++++++++++ .../cdr-client/application-customer.yaml.j2 | 36 ++ .../cdr-client/logback-service.xml.j2 | 38 +++ .../etc/systemd/system/cdr-client.service.j2 | 23 ++ roles/curaline/vars/Debian.yml | 2 + roles/curaline/vars/RedHat.yml | 3 + roles/curaline/vars/Ubuntu.yml | 2 + roles/curaline/vars/main.yml | 20 ++ 18 files changed, 952 insertions(+) create mode 100644 playbooks/curaline.yml create mode 100644 roles/curaline/README.md create mode 100644 roles/curaline/defaults/main.yml create mode 100644 roles/curaline/handlers/main.yml create mode 100644 roles/curaline/meta/argument_specs.yml create mode 100644 roles/curaline/tasks/main.yml create mode 100644 roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2 create mode 100644 roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/logback-service.xml.j2 create mode 100644 roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 create mode 100644 roles/curaline/vars/Debian.yml create mode 100644 roles/curaline/vars/RedHat.yml create mode 100644 roles/curaline/vars/Ubuntu.yml create mode 100644 roles/curaline/vars/main.yml 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 9b684d2c2..a03f3eac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,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:graylog_datanode, role:graylog_server**: Add template for Graylog 7.2. * **role:duplicity**: `duplicity__archive_dir` sets the directory of duplicity's local metadata cache, for example to keep the cache of a separate long-term backup apart from the daily one. Without it, the cache stays in `~/.cache/duplicity`. * **role:systemd_unit**: Manages systemd socket units with `systemd_unit__sockets__host_var` / `systemd_unit__sockets__group_var`, for example to forward a TCP port with `systemd-socket-proxyd` without installing a proxy server. diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 082e78818..b3cbd5dde 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/playbooks/README.md b/playbooks/README.md index 71c75f0d7..fdf300980 100644 --- a/playbooks/README.md +++ b/playbooks/README.md @@ -212,6 +212,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..09dbb6ee8 --- /dev/null +++ b/roles/curaline/README.md @@ -0,0 +1,307 @@ +# 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 `cdr-client.service` under a system account of its own. + + +*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 installation directory belongs to the `curaline` account, because the client writes to it while it runs, for example its `.cache`. +* The folders of the connectors (`source-folder`, `target-folder`, `source-archive-folder`) and `curaline__local_folder` are created and handed to `curaline` without touching their content. Parent directories are created owned by `root`. The client creates the subfolders below them, such as `error`, itself. +* 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 replaces the client secret in `application-customer.yaml` by itself, and the next run of the role sets the one from the inventory again. Leave it at `false` unless the secret in the inventory is renewed 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`. + + +## Known Limitations + +* Only `x86_64` hosts are supported. +* 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`. + + +## 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 curaline system user and group, and the 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: 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: 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 it is not installed yet. +* Sets the SELinux file context of the client (Red Hat family with SELinux). +* Triggers: 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__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'` + +`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'` + +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__local_folder: '/data/cdr-inflight' +curaline__renew_credential: false +curaline__schedule_delay: 'PT10M' +curaline__service_enabled: true +curaline__service_state: 'started' +``` + + +## 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/cdr-client/cdr-client-service.log`. + +**The run aborts with `curaline__version must look like "6.2.1", at least one connector must be present ...`** + +* Set `curaline__version` to a release listed on the curaLINE download page, and give the host at least one connector in `curaline__connectors__host_var` or `curaline__connectors__group_var`. + + +## 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..1bef0558e --- /dev/null +++ b/roles/curaline/defaults/main.yml @@ -0,0 +1,37 @@ +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__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' +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' diff --git a/roles/curaline/handlers/main.yml b/roles/curaline/handlers/main.yml new file mode 100644 index 000000000..3d7e1f260 --- /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 cdr-client' + ansible.builtin.service: + name: 'cdr-client' + 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..a470f64d2 --- /dev/null +++ b/roles/curaline/meta/argument_specs.yml @@ -0,0 +1,164 @@ +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__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__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..ace1e0fc5 --- /dev/null +++ b/roles/curaline/tasks/main.yml @@ -0,0 +1,260 @@ +- 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. + - name: 'Check variable constraints' + ansible.builtin.assert: + that: + - 'curaline__version is regex("^[0-9]+\.[0-9]+\.[0-9]+$")' + - '__curaline__connectors_present | length > 0' + - 'ansible_facts["architecture"] == "x86_64"' + quiet: true + fail_msg: >- + curaline__version must look like "6.2.1", at least one connector must be present in + curaline__connectors__*_var, and the host must be x86_64 (the curaLINE client is only + published for linux-amd64). + + 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 + + - name: 'groupadd --system curaline' + ansible.builtin.group: + name: 'curaline' + state: 'present' + system: true + + - name: 'useradd --system curaline' + ansible.builtin.user: + name: 'curaline' + comment: 'curaLINE Service Account' + group: 'curaline' + home: '{{ __curaline__install_dir }}' + create_home: false + shell: '/usr/sbin/nologin' + system: true + state: 'present' + + - name: 'mkdir -p {{ __curaline__config_dir }}' + ansible.builtin.file: + path: '{{ __curaline__config_dir }}' + state: 'directory' + owner: 'curaline' + group: 'curaline' + mode: 0o750 + + # the folders of the connectors are not recursed into on purpose: the files in them belong to + # whatever delivers or collects them. + - name: 'mkdir -p {{ __curaline__folders | join(" ") }}' + ansible.builtin.file: + path: '{{ item }}' + state: 'directory' + owner: 'curaline' + group: 'curaline' + mode: 0o755 + loop: '{{ __curaline__folders }}' + + tags: + - 'curaline' + + +- block: + + - name: 'mkdir -p {{ __curaline__install_dir }}' + ansible.builtin.file: + path: '{{ __curaline__install_dir }}' + state: 'directory' + owner: 'curaline' + group: 'curaline' + 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 and the client unpacks into and writes to its own + # directory (for example `.cache`), so the whole directory belongs to the service account. + - 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' + group: 'curaline' + 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' + group: 'curaline' + extra_opts: + - '--strip-components=1' + notify: 'curaline: restart 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: 'root' + group: 'root' + mode: 0o644 + + 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' + group: 'curaline' + mode: 0o640 # contains the client secret + diff: false # keep the client secret out of `--diff` output + notify: 'curaline: restart 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' + group: 'curaline' + mode: 0o640 + notify: 'curaline: restart cdr-client' + + # the unit is templated whole, not as a drop-in, because the tarball ships none that fits: its + # unit uses a dynamic user, while the client has to own its installation and data folders. + - name: 'Deploy /etc/systemd/system/cdr-client.service' + ansible.builtin.template: + backup: true + src: 'etc/systemd/system/cdr-client.service.j2' + dest: '/etc/systemd/system/cdr-client.service' + owner: 'root' + group: 'root' + mode: 0o644 + register: '__curaline__unit_result' + notify: 'curaline: restart 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") }} cdr-client.service' + ansible.builtin.service: + name: 'cdr-client.service' + enabled: '{{ curaline__service_enabled | bool }}' + + - name: 'systemctl {{ curaline__service_state | regex_replace("p?ed$", "") }} cdr-client.service' + ansible.builtin.service: + name: '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..9fd1c3002 --- /dev/null +++ b/roles/curaline/templates/etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2 @@ -0,0 +1,36 @@ +# {{ ansible_managed }} +# 2026101502 +version: "1.2" +client: + file-synchronization-enabled: {{ curaline__file_synchronization_enabled | bool | lower }} + local-folder: {{ curaline__local_folder }} + proxy-config: + url: + username: + password: + idp-credentials: + tenant-id: {{ curaline__tenant_id }} + client-id: {{ curaline__client_id | to_json }} + client-secret: {{ curaline__client_secret | to_json }} + scope: {{ curaline__scope }} + renew-credential: {{ curaline__renew_credential | bool | lower }} + last-credential-renewal-time: {{ curaline__last_credential_renewal_time }} + idp-endpoint: {{ curaline__idp_endpoint }} + schedule-delay: {{ curaline__schedule_delay }} + file-busy-test-strategy: {{ curaline__file_busy_test_strategy }} + cdr-api: + scheme: {{ curaline__cdr_api_scheme }} + host: {{ curaline__cdr_api_host }} + port: {{ curaline__cdr_api_port }} + base-path: {{ curaline__cdr_api_base_path }} + credential-api: + scheme: {{ curaline__credential_api_scheme }} + port: {{ curaline__credential_api_port }} + host: {{ curaline__credential_api_host }} + base-path: {{ curaline__credential_api_base_path }} + 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/cdr-client.service.j2 b/roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 new file mode 100644 index 000000000..8e2914b46 --- /dev/null +++ b/roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 @@ -0,0 +1,23 @@ +# {{ ansible_managed }} +# 2026101501 + +[Unit] +Description=curaLINE CDR Client +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +User=curaline +Group=curaline +WorkingDirectory={{ __curaline__install_dir }} +ExecStart={{ __curaline__install_dir }}/bin/cdr-client-service +LogsDirectory=cdr-client +LogsDirectoryMode=0750 +Restart=on-failure +RestartSec=10s +SuccessExitStatus=143 +SyslogIdentifier=cdr-client + +[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..8173615c3 --- /dev/null +++ b/roles/curaline/vars/main.yml @@ -0,0 +1,20 @@ +__curaline__config_dir: '/etc/swisscom-schweiz-ag/cdr-client' +__curaline__install_dir: '/usr/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 + }}' From 01ff46882bad2bd7993987699fc97103b7248898 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Thu, 15 Oct 2026 11:37:01 +0200 Subject: [PATCH 2/9] test(roles/curaline): add scenario Runs on all eight targets of COMPATIBILITY.md. verify.yml asks the running client for its health, version, schedule delay and watched folder, and on the RHEL family checks that SELinux is enforcing and the client runs in unconfined_service_t. --- extensions/molecule/curaline/converge.yml | 2 + .../group_vars/systems_under_test.yml | 24 ++++ .../molecule/curaline/inventory/hosts.yml | 21 ++++ extensions/molecule/curaline/molecule.yml | 4 + extensions/molecule/curaline/verify.yml | 110 ++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 extensions/molecule/curaline/converge.yml create mode 100644 extensions/molecule/curaline/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/curaline/inventory/hosts.yml create mode 100644 extensions/molecule/curaline/molecule.yml create mode 100644 extensions/molecule/curaline/verify.yml diff --git a/extensions/molecule/curaline/converge.yml b/extensions/molecule/curaline/converge.yml new file mode 100644 index 000000000..543ddfa7a --- /dev/null +++ b/extensions/molecule/curaline/converge.yml @@ -0,0 +1,2 @@ +- name: 'Converge curaline playbook' + ansible.builtin.import_playbook: 'linuxfabrik.lfops.curaline' diff --git a/extensions/molecule/curaline/inventory/group_vars/systems_under_test.yml b/extensions/molecule/curaline/inventory/group_vars/systems_under_test.yml new file mode 100644 index 000000000..0516bb6d5 --- /dev/null +++ b/extensions/molecule/curaline/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/inventory/hosts.yml b/extensions/molecule/curaline/inventory/hosts.yml new file mode 100644 index 000000000..0fa5a5c06 --- /dev/null +++ b/extensions/molecule/curaline/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/molecule.yml b/extensions/molecule/curaline/molecule.yml new file mode 100644 index 000000000..53a19cb45 --- /dev/null +++ b/extensions/molecule/curaline/molecule.yml @@ -0,0 +1,4 @@ +# Molecule scenario marker. 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/verify.yml b/extensions/molecule/curaline/verify.yml new file mode 100644 index 000000000..d920a2023 --- /dev/null +++ b/extensions/molecule/curaline/verify.yml @@ -0,0 +1,110 @@ +# What only the running system can confirm is that the client starts from the unpacked +# download under its own 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 cdr-client.service is running and enabled' + ansible.builtin.assert: + that: + - 'ansible_facts["services"]["cdr-client.service"]["state"] == "running"' + - 'ansible_facts["services"]["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 cdr-client --property=MainPID --value' # noqa command-instead-of-module (read-only property query) + ansible.builtin.command: 'systemctl show cdr-client --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' + + - name: 'Assert that the client runs as curaline' + ansible.builtin.assert: + that: '__molecule__curaline_proc_result["stat"]["pw_name"] == "curaline"' + + # 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/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"]' From b802d007501e002c8886c28788837c70b728bc00 Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Fri, 16 Oct 2026 12:17:13 +0200 Subject: [PATCH 3/9] feat(roles/curaline): run under a configurable user and align the unit with the Debian package Add `curaline__user` and `curaline__group`, so the client can run under the `hnet` account of an existing H-Net SecureClient installation. An existing user is taken over unchanged, and existing connector folders keep their owner and mode. The systemd unit now follows the one of the Debian package (name, directories, HOME), but with a static user instead of `DynamicUser=yes` and explicit sandboxing. --- .../curaline/{ => install}/converge.yml | 0 .../group_vars/systems_under_test.yml | 0 .../{ => install}/inventory/hosts.yml | 0 .../curaline/{ => install}/molecule.yml | 0 .../curaline/{ => install}/verify.yml | 0 roles/curaline/README.md | 52 +++++++-- roles/curaline/defaults/main.yml | 2 + roles/curaline/handlers/main.yml | 4 +- roles/curaline/meta/argument_specs.yml | 12 ++ roles/curaline/tasks/main.yml | 104 +++++++++++------- .../etc/systemd/system/cdr-client.service.j2 | 23 ---- .../swisscom-schweiz-ag-cdr-client.service.j2 | 39 +++++++ roles/curaline/vars/main.yml | 2 + 13 files changed, 163 insertions(+), 75 deletions(-) rename extensions/molecule/curaline/{ => install}/converge.yml (100%) rename extensions/molecule/curaline/{ => install}/inventory/group_vars/systems_under_test.yml (100%) rename extensions/molecule/curaline/{ => install}/inventory/hosts.yml (100%) rename extensions/molecule/curaline/{ => install}/molecule.yml (100%) rename extensions/molecule/curaline/{ => install}/verify.yml (100%) delete mode 100644 roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 create mode 100644 roles/curaline/templates/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 diff --git a/extensions/molecule/curaline/converge.yml b/extensions/molecule/curaline/install/converge.yml similarity index 100% rename from extensions/molecule/curaline/converge.yml rename to extensions/molecule/curaline/install/converge.yml diff --git a/extensions/molecule/curaline/inventory/group_vars/systems_under_test.yml b/extensions/molecule/curaline/install/inventory/group_vars/systems_under_test.yml similarity index 100% rename from extensions/molecule/curaline/inventory/group_vars/systems_under_test.yml rename to extensions/molecule/curaline/install/inventory/group_vars/systems_under_test.yml diff --git a/extensions/molecule/curaline/inventory/hosts.yml b/extensions/molecule/curaline/install/inventory/hosts.yml similarity index 100% rename from extensions/molecule/curaline/inventory/hosts.yml rename to extensions/molecule/curaline/install/inventory/hosts.yml diff --git a/extensions/molecule/curaline/molecule.yml b/extensions/molecule/curaline/install/molecule.yml similarity index 100% rename from extensions/molecule/curaline/molecule.yml rename to extensions/molecule/curaline/install/molecule.yml diff --git a/extensions/molecule/curaline/verify.yml b/extensions/molecule/curaline/install/verify.yml similarity index 100% rename from extensions/molecule/curaline/verify.yml rename to extensions/molecule/curaline/install/verify.yml diff --git a/roles/curaline/README.md b/roles/curaline/README.md index 09dbb6ee8..b982c8e74 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -1,6 +1,6 @@ # 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 `cdr-client.service` under a system account of its own. +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.* @@ -11,8 +11,10 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom * 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 installation directory belongs to the `curaline` account, because the client writes to it while it runs, for example its `.cache`. -* The folders of the connectors (`source-folder`, `target-folder`, `source-archive-folder`) and `curaline__local_folder` are created and handed to `curaline` without touching their content. Parent directories are created owned by `root`. The client creates the subfolders below them, such as `error`, itself. +* 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 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. A unit installed by the Debian package is overridden by the one of the role. +* 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. * 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 replaces the client secret in `application-customer.yaml` by itself, and the next run of the role sets the one from the inventory again. Leave it at `false` unless the secret in the inventory is renewed 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`. @@ -32,6 +34,20 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom * 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. @@ -42,18 +58,18 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom `curaline` * Installs the required packages. -* Creates the curaline system user and group, and the folders of the connectors. +* 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: cdr-client.service restart. +* 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: cdr-client.service restart. +* Triggers: swisscom-schweiz-ag-cdr-client.service restart. `curaline:state` @@ -62,9 +78,9 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom `curaline:update` -* Installs the client in the version of `curaline__version`, if it is not installed yet. +* Installs the client in the version of `curaline__version`, if it is not installed yet, and hands the installation directory to `curaline__user`. * Sets the SELinux file context of the client (Red Hat family with SELinux). -* Triggers: cdr-client.service restart. +* Triggers: swisscom-schweiz-ag-cdr-client.service restart. ## Mandatory Role Variables @@ -213,6 +229,12 @@ curaline__version: '6.2.1' * 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: `'curaline'` + `curaline__idp_endpoint` * The OAuth2 token endpoint of the identity provider. @@ -268,6 +290,12 @@ curaline__version: '6.2.1' * 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: `'curaline'` + Example: ```yaml # optional @@ -278,11 +306,13 @@ 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' ``` @@ -290,7 +320,11 @@ curaline__service_state: 'started' **`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/cdr-client/cdr-client-service.log`. +* 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", at least one connector must be present ...`** diff --git a/roles/curaline/defaults/main.yml b/roles/curaline/defaults/main.yml index 1bef0558e..65bb98dbf 100644 --- a/roles/curaline/defaults/main.yml +++ b/roles/curaline/defaults/main.yml @@ -23,6 +23,7 @@ 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: 'curaline' 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 @@ -35,3 +36,4 @@ 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: 'curaline' diff --git a/roles/curaline/handlers/main.yml b/roles/curaline/handlers/main.yml index 3d7e1f260..9ffcd2995 100644 --- a/roles/curaline/handlers/main.yml +++ b/roles/curaline/handlers/main.yml @@ -2,9 +2,9 @@ # (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 cdr-client' +- name: 'curaline: restart swisscom-schweiz-ag-cdr-client' ansible.builtin.service: - name: 'cdr-client' + name: 'swisscom-schweiz-ag-cdr-client.service' state: 'restarted' when: - 'not (lfops__skip_restart_handlers | d(false) | bool)' diff --git a/roles/curaline/meta/argument_specs.yml b/roles/curaline/meta/argument_specs.yml index a470f64d2..8b6ad0c8c 100644 --- a/roles/curaline/meta/argument_specs.yml +++ b/roles/curaline/meta/argument_specs.yml @@ -101,6 +101,12 @@ argument_specs: default: true description: 'Whether the client uploads and downloads files.' + curaline__group: + type: 'str' + required: false + default: 'curaline' + description: 'Group of the service, created as a system group if it does not exist.' + curaline__idp_endpoint: type: 'str' required: false @@ -158,6 +164,12 @@ argument_specs: default: '70b434db-eccb-4280-95dc-1e59220aca55' description: 'The Entra ID tenant that issues the tokens.' + curaline__user: + type: 'str' + required: false + default: 'curaline' + 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 diff --git a/roles/curaline/tasks/main.yml b/roles/curaline/tasks/main.yml index ace1e0fc5..32062b925 100644 --- a/roles/curaline/tasks/main.yml +++ b/roles/curaline/tasks/main.yml @@ -37,41 +37,58 @@ retries: 3 # ride out a brief outage of the package repository delay: 10 - - name: 'groupadd --system curaline' + # an existing group keeps its GID, `ansible.builtin.group` only sets `system` on creation. + - name: 'groupadd --system {{ curaline__group }}' ansible.builtin.group: - name: 'curaline' + name: '{{ curaline__group }}' state: 'present' system: true - - name: 'useradd --system curaline' + # 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' + name: '{{ curaline__user }}' comment: 'curaLINE Service Account' - group: 'curaline' - home: '{{ __curaline__install_dir }}' + 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' - group: 'curaline' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' mode: 0o750 - # the folders of the connectors are not recursed into on purpose: the files in them belong to - # whatever delivers or collects them. + - 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 }}' + path: '{{ item["item"] }}' state: 'directory' - owner: 'curaline' - group: 'curaline' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' mode: 0o755 - loop: '{{ __curaline__folders }}' + loop: '{{ __curaline__folders_stat_result["results"] | rejectattr("stat.exists") | list }}' + loop_control: + label: '{{ item["item"] }}' tags: - 'curaline' @@ -83,8 +100,8 @@ ansible.builtin.file: path: '{{ __curaline__install_dir }}' state: 'directory' - owner: 'curaline' - group: 'curaline' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' mode: 0o755 - name: 'cat {{ __curaline__install_dir }}/.ansible-version' @@ -94,8 +111,7 @@ failed_when: false check_mode: false - # the tarball ships its own Java runtime and the client unpacks into and writes to its own - # directory (for example `.cache`), so the whole directory belongs to the service account. + # 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' @@ -128,19 +144,19 @@ ansible.builtin.file: path: '{{ __curaline__install_dir }}' state: 'directory' - owner: 'curaline' - group: 'curaline' + 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' - group: 'curaline' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' extra_opts: - '--strip-components=1' - notify: 'curaline: restart cdr-client' + 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. @@ -148,10 +164,17 @@ ansible.builtin.copy: dest: '{{ __curaline__install_dir }}/.ansible-version' content: '{{ curaline__version }}' - owner: 'root' - group: 'root' + 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' @@ -164,34 +187,33 @@ backup: true src: 'etc/swisscom-schweiz-ag/cdr-client/application-customer.yaml.j2' dest: '{{ __curaline__config_dir }}/application-customer.yaml' - owner: 'curaline' - group: 'curaline' + 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 cdr-client' + 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' - group: 'curaline' + owner: '{{ curaline__user }}' + group: '{{ curaline__group }}' mode: 0o640 - notify: 'curaline: restart cdr-client' + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' - # the unit is templated whole, not as a drop-in, because the tarball ships none that fits: its - # unit uses a dynamic user, while the client has to own its installation and data folders. - - name: 'Deploy /etc/systemd/system/cdr-client.service' + # the unit is templated whole, not as a drop-in, because the one of the Debian package uses a dynamic user, while the client has to run under a static account that owns its installation and data folders. the name is the same, so this unit takes precedence over the one of the package. + - name: 'Deploy /etc/systemd/system/swisscom-schweiz-ag-cdr-client.service' ansible.builtin.template: backup: true - src: 'etc/systemd/system/cdr-client.service.j2' - dest: '/etc/systemd/system/cdr-client.service' + 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 cdr-client' + notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' - name: 'systemctl daemon-reload' # noqa no-handler would require flush_handlers here anyway ansible.builtin.systemd: @@ -232,14 +254,14 @@ - 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") }} cdr-client.service' + - name: 'systemctl {{ curaline__service_enabled | bool | ternary("enable", "disable") }} swisscom-schweiz-ag-cdr-client.service' ansible.builtin.service: - name: 'cdr-client.service' + name: 'swisscom-schweiz-ag-cdr-client.service' enabled: '{{ curaline__service_enabled | bool }}' - - name: 'systemctl {{ curaline__service_state | regex_replace("p?ed$", "") }} cdr-client.service' + - name: 'systemctl {{ curaline__service_state | regex_replace("p?ed$", "") }} swisscom-schweiz-ag-cdr-client.service' ansible.builtin.service: - name: 'cdr-client.service' + name: 'swisscom-schweiz-ag-cdr-client.service' state: '{{ curaline__service_state }}' register: '__curaline__service_state_result' diff --git a/roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 b/roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 deleted file mode 100644 index 8e2914b46..000000000 --- a/roles/curaline/templates/etc/systemd/system/cdr-client.service.j2 +++ /dev/null @@ -1,23 +0,0 @@ -# {{ ansible_managed }} -# 2026101501 - -[Unit] -Description=curaLINE CDR Client -After=network-online.target -Wants=network-online.target - -[Service] -Type=exec -User=curaline -Group=curaline -WorkingDirectory={{ __curaline__install_dir }} -ExecStart={{ __curaline__install_dir }}/bin/cdr-client-service -LogsDirectory=cdr-client -LogsDirectoryMode=0750 -Restart=on-failure -RestartSec=10s -SuccessExitStatus=143 -SyslogIdentifier=cdr-client - -[Install] -WantedBy=multi-user.target 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..1f990ceed --- /dev/null +++ b/roles/curaline/templates/etc/systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 @@ -0,0 +1,39 @@ +# {{ ansible_managed }} +# 2026101601 + +# 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/main.yml b/roles/curaline/vars/main.yml index 8173615c3..dff30d953 100644 --- a/roles/curaline/vars/main.yml +++ b/roles/curaline/vars/main.yml @@ -1,5 +1,7 @@ __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. From 1674fd1a2a7224ade1451ecba2febc6a4f069812 Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Fri, 16 Oct 2026 12:17:14 +0200 Subject: [PATCH 4/9] test(roles/curaline): add hnet scenario Move the existing scenario to `curaline/install` and add `curaline/hnet`, which prepares a host like an H-Net SecureClient installation and verifies that the role takes it over without changing the account or the existing folders. --- .../molecule/curaline/hnet/converge.yml | 2 + .../group_vars/systems_under_test.yml | 28 ++++++++ .../curaline/hnet/inventory/hosts.yml | 21 ++++++ .../molecule/curaline/hnet/molecule.yml | 6 ++ extensions/molecule/curaline/hnet/prepare.yml | 66 +++++++++++++++++++ extensions/molecule/curaline/hnet/verify.yml | 59 +++++++++++++++++ .../molecule/curaline/install/molecule.yml | 5 +- .../molecule/curaline/install/verify.yml | 19 +++--- 8 files changed, 196 insertions(+), 10 deletions(-) create mode 100644 extensions/molecule/curaline/hnet/converge.yml create mode 100644 extensions/molecule/curaline/hnet/inventory/group_vars/systems_under_test.yml create mode 100644 extensions/molecule/curaline/hnet/inventory/hosts.yml create mode 100644 extensions/molecule/curaline/hnet/molecule.yml create mode 100644 extensions/molecule/curaline/hnet/prepare.yml create mode 100644 extensions/molecule/curaline/hnet/verify.yml 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..e6b79ed3d --- /dev/null +++ b/extensions/molecule/curaline/hnet/molecule.yml @@ -0,0 +1,6 @@ +# 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..f6b1ae6ba --- /dev/null +++ b/extensions/molecule/curaline/hnet/prepare.yml @@ -0,0 +1,66 @@ +# Wait for the systems under test and gather their facts, exactly 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..105f148d3 --- /dev/null +++ b/extensions/molecule/curaline/hnet/verify.yml @@ -0,0 +1,59 @@ +# 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/molecule.yml b/extensions/molecule/curaline/install/molecule.yml index 53a19cb45..0e5759258 100644 --- a/extensions/molecule/curaline/install/molecule.yml +++ b/extensions/molecule/curaline/install/molecule.yml @@ -1,4 +1,7 @@ -# Molecule scenario marker. The shared config.yml already provisions libvirt/KVM VMs, so this +# 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 index d920a2023..d0935bb03 100644 --- a/extensions/molecule/curaline/install/verify.yml +++ b/extensions/molecule/curaline/install/verify.yml @@ -1,5 +1,5 @@ # What only the running system can confirm is that the client starts from the unpacked -# download under its own account, that SELinux lets systemd execute it, and that it reads the +# 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' @@ -10,11 +10,11 @@ - name: 'Gather the service facts' ansible.builtin.service_facts: # yamllint disable-line rule:empty-values - - name: 'Assert that cdr-client.service is running and enabled' + - name: 'Assert that swisscom-schweiz-ag-cdr-client.service is running and enabled' ansible.builtin.assert: that: - - 'ansible_facts["services"]["cdr-client.service"]["state"] == "running"' - - 'ansible_facts["services"]["cdr-client.service"]["status"] == "enabled"' + - '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: @@ -47,8 +47,8 @@ ansible.builtin.assert: that: '__molecule__curaline_info_result["json"]["build"]["version"] == curaline__version' - - name: 'systemctl show cdr-client --property=MainPID --value' # noqa command-instead-of-module (read-only property query) - ansible.builtin.command: 'systemctl show cdr-client --property=MainPID --value' + - 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' @@ -57,13 +57,14 @@ path: '/proc/{{ __molecule__curaline_mainpid_result["stdout"] }}' register: '__molecule__curaline_proc_result' - - name: 'Assert that the client runs as curaline' + # 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("curaline") }}' ansible.builtin.assert: - that: '__molecule__curaline_proc_result["stat"]["pw_name"] == "curaline"' + that: '__molecule__curaline_proc_result["stat"]["pw_name"] == curaline__user | d("curaline")' # 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/cdr-client/cdr-client-service.log" + 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 From 7e3fc61945a16427f25d6ed0e79bc5bd6e6f44a2 Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Fri, 16 Oct 2026 13:29:58 +0200 Subject: [PATCH 5/9] docs(roles/curaline): document the deviating default of curaline__local_folder --- roles/curaline/README.md | 1 + roles/curaline/defaults/main.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/roles/curaline/README.md b/roles/curaline/README.md index b982c8e74..44c721658 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -252,6 +252,7 @@ curaline__version: '6.2.1' * 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` diff --git a/roles/curaline/defaults/main.yml b/roles/curaline/defaults/main.yml index 65bb98dbf..510c6e102 100644 --- a/roles/curaline/defaults/main.yml +++ b/roles/curaline/defaults/main.yml @@ -28,7 +28,7 @@ curaline__idp_endpoint: 'https://login.microsoftonline.com/{{ curaline__tenant_i # 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' +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' From cac5b70b7e4c57961e5a6a77c48549b9b9b2e1ec Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Fri, 16 Oct 2026 13:59:48 +0200 Subject: [PATCH 6/9] feat(roles/curaline): use the user and group name of the Debian package by default --- extensions/molecule/curaline/install/verify.yml | 4 ++-- roles/curaline/README.md | 6 +++--- roles/curaline/defaults/main.yml | 4 ++-- roles/curaline/meta/argument_specs.yml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/molecule/curaline/install/verify.yml b/extensions/molecule/curaline/install/verify.yml index d0935bb03..0c9fee32c 100644 --- a/extensions/molecule/curaline/install/verify.yml +++ b/extensions/molecule/curaline/install/verify.yml @@ -58,9 +58,9 @@ 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("curaline") }}' + - 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("curaline")' + 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' diff --git a/roles/curaline/README.md b/roles/curaline/README.md index 44c721658..ad718156f 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -13,7 +13,7 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom * 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 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. A unit installed by the Debian package is overridden by the one of the role. +* The systemd unit is modelled on the one of the Debian package, with the same name, user, group 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. A unit installed by the Debian package is overridden by the one of the role. * 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. * 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 replaces the client secret in `application-customer.yaml` by itself, and the next run of the role sets the one from the inventory again. Leave it at `false` unless the secret in the inventory is renewed by hand. @@ -233,7 +233,7 @@ curaline__version: '6.2.1' * 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: `'curaline'` +* Default: `'swisscom-schweiz-ag-cdr-client'` `curaline__idp_endpoint` @@ -295,7 +295,7 @@ curaline__version: '6.2.1' * 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: `'curaline'` +* Default: `'swisscom-schweiz-ag-cdr-client'` Example: ```yaml diff --git a/roles/curaline/defaults/main.yml b/roles/curaline/defaults/main.yml index 510c6e102..ae1884cfb 100644 --- a/roles/curaline/defaults/main.yml +++ b/roles/curaline/defaults/main.yml @@ -23,7 +23,7 @@ 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: 'curaline' +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 @@ -36,4 +36,4 @@ 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: 'curaline' +curaline__user: 'swisscom-schweiz-ag-cdr-client' diff --git a/roles/curaline/meta/argument_specs.yml b/roles/curaline/meta/argument_specs.yml index 8b6ad0c8c..0d4bce7f2 100644 --- a/roles/curaline/meta/argument_specs.yml +++ b/roles/curaline/meta/argument_specs.yml @@ -104,7 +104,7 @@ argument_specs: curaline__group: type: 'str' required: false - default: 'curaline' + default: 'swisscom-schweiz-ag-cdr-client' description: 'Group of the service, created as a system group if it does not exist.' curaline__idp_endpoint: @@ -167,7 +167,7 @@ argument_specs: curaline__user: type: 'str' required: false - default: 'curaline' + 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: From c38e0b6f26e83b63f8a570adcee612a1634c2d14 Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Fri, 16 Oct 2026 14:25:32 +0200 Subject: [PATCH 7/9] fix(roles/curaline): quote the paths in ReadWritePaths= and document that hosts with the Debian package are not supported --- roles/curaline/README.md | 3 ++- .../systemd/system/swisscom-schweiz-ag-cdr-client.service.j2 | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/roles/curaline/README.md b/roles/curaline/README.md index ad718156f..842d6c452 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -13,7 +13,7 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom * 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, group 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. A unit installed by the Debian package is overridden by the one of the role. +* 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. * 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 replaces the client secret in `application-customer.yaml` by itself, and the next run of the role sets the one from the inventory again. Leave it at `false` unless the secret in the inventory is renewed by hand. @@ -24,6 +24,7 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom ## 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. 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 index 1f990ceed..13d740cbc 100644 --- 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 @@ -1,5 +1,5 @@ # {{ ansible_managed }} -# 2026101601 +# 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. @@ -23,7 +23,7 @@ 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(' ') }} +ReadWritePaths="{{ ([__curaline__install_dir, __curaline__config_dir] + __curaline__folders) | join('" "') }}" Restart=always RestartSec=10s RestrictSUIDSGID=yes From 4337b85e4a3f478e55d1cd6bacf57012014df58e Mon Sep 17 00:00:00 2001 From: Danyal Berchtold Date: Mon, 19 Oct 2026 11:15:54 +0200 Subject: [PATCH 8/9] docs(roles/curaline): document the requirements on source folder files, the effect of renew_credential and the SELinux label of downloads --- roles/curaline/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/roles/curaline/README.md b/roles/curaline/README.md index 842d6c452..b3b6c2c7d 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -15,10 +15,12 @@ This role installs and configures the curaLINE Client (`cdr-client`) of Swisscom * 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 replaces the client secret in `application-customer.yaml` by itself, and the next run of the role sets the one from the inventory again. Leave it at `false` unless the secret in the inventory is renewed by hand. +* 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 From de24ab1a604ac169e98c8a8dbee5b71b2f3e5c06 Mon Sep 17 00:00:00 2001 From: Navid Sassan Date: Tue, 20 Oct 2026 11:07:29 +0200 Subject: [PATCH 9/9] fix(roles/curaline): check the connector keys, quote the configuration values and name the failing check The single assert is split so the abort names the failing condition and the actual value. Comments are wrapped and the unit template comment no longer contradicts the unsupported Debian package. --- .../molecule/curaline/hnet/molecule.yml | 9 +++- extensions/molecule/curaline/hnet/prepare.yml | 9 ++-- extensions/molecule/curaline/hnet/verify.yml | 13 +++-- .../molecule/curaline/install/verify.yml | 3 +- roles/curaline/README.md | 10 ++-- roles/curaline/tasks/main.yml | 49 +++++++++++++++---- .../cdr-client/application-customer.yaml.j2 | 28 +++++------ 7 files changed, 85 insertions(+), 36 deletions(-) diff --git a/extensions/molecule/curaline/hnet/molecule.yml b/extensions/molecule/curaline/hnet/molecule.yml index e6b79ed3d..c56bcd86f 100644 --- a/extensions/molecule/curaline/hnet/molecule.yml +++ b/extensions/molecule/curaline/hnet/molecule.yml @@ -1,6 +1,11 @@ -# Molecule scenario marker for the 'hnet' sub-scenario, run with `molecule test --scenario-name curaline/hnet`. +# 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. +# 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 index f6b1ae6ba..7945915a7 100644 --- a/extensions/molecule/curaline/hnet/prepare.yml +++ b/extensions/molecule/curaline/hnet/prepare.yml @@ -1,9 +1,11 @@ -# Wait for the systems under test and gather their facts, exactly as every other scenario does. +# 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. +# 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' @@ -33,7 +35,8 @@ - '/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. + # 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' diff --git a/extensions/molecule/curaline/hnet/verify.yml b/extensions/molecule/curaline/hnet/verify.yml index 105f148d3..677b469c0 100644 --- a/extensions/molecule/curaline/hnet/verify.yml +++ b/extensions/molecule/curaline/hnet/verify.yml @@ -1,9 +1,12 @@ -# The client has to work under the `hnet` account exactly as under its own one, so the checks of the install scenario run first. +# 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. +# 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 @@ -33,7 +36,8 @@ - '__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. + # 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' @@ -53,7 +57,8 @@ 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. + # 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/verify.yml b/extensions/molecule/curaline/install/verify.yml index 0c9fee32c..e74b35630 100644 --- a/extensions/molecule/curaline/install/verify.yml +++ b/extensions/molecule/curaline/install/verify.yml @@ -57,7 +57,8 @@ 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. + # 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")' diff --git a/roles/curaline/README.md b/roles/curaline/README.md index b3b6c2c7d..328dfb552 100644 --- a/roles/curaline/README.md +++ b/roles/curaline/README.md @@ -81,7 +81,7 @@ The role leaves the `hnet` account, `/home/hnet`, `/var/hnet` and the SELinux se `curaline:update` -* Installs the client in the version of `curaline__version`, if it is not installed yet, and hands the installation directory to `curaline__user`. +* 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. @@ -330,9 +330,13 @@ curaline__user: 'hnet' * 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", at least one connector must be present ...`** +**The run aborts with `curaline__version must look like "6.2.1"`** -* Set `curaline__version` to a release listed on the curaLINE download page, and give the host at least one connector in `curaline__connectors__host_var` or `curaline__connectors__group_var`. +* 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 diff --git a/roles/curaline/tasks/main.yml b/roles/curaline/tasks/main.yml index 32062b925..16925aa5d 100644 --- a/roles/curaline/tasks/main.yml +++ b/roles/curaline/tasks/main.yml @@ -11,18 +11,41 @@ - block: - # simple checks (required, type, choices) live in meta/argument_specs.yml. + # 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: >- - curaline__version must look like "6.2.1", at least one connector must be present in - curaline__connectors__*_var, and the host must be x86_64 (the curaLINE client is only - published for linux-amd64). + fail_msg: 'The curaLINE client is only published for linux-amd64, this host is {{ ansible_facts["architecture"] }}.' tags: - 'always' @@ -44,7 +67,9 @@ 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. + # 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' @@ -78,7 +103,9 @@ 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. + # 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"] }}' @@ -111,7 +138,8 @@ 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. + # 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' @@ -203,7 +231,10 @@ mode: 0o640 notify: 'curaline: restart swisscom-schweiz-ag-cdr-client' - # the unit is templated whole, not as a drop-in, because the one of the Debian package uses a dynamic user, while the client has to run under a static account that owns its installation and data folders. the name is the same, so this unit takes precedence over the one of the package. + # 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 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 index 9fd1c3002..5e0b45b5d 100644 --- 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 @@ -1,33 +1,33 @@ # {{ ansible_managed }} -# 2026101502 +# 2026102001 version: "1.2" client: file-synchronization-enabled: {{ curaline__file_synchronization_enabled | bool | lower }} - local-folder: {{ curaline__local_folder }} + local-folder: {{ curaline__local_folder | to_json }} proxy-config: url: username: password: idp-credentials: - tenant-id: {{ curaline__tenant_id }} + tenant-id: {{ curaline__tenant_id | to_json }} client-id: {{ curaline__client_id | to_json }} client-secret: {{ curaline__client_secret | to_json }} - scope: {{ curaline__scope }} + scope: {{ curaline__scope | to_json }} renew-credential: {{ curaline__renew_credential | bool | lower }} - last-credential-renewal-time: {{ curaline__last_credential_renewal_time }} - idp-endpoint: {{ curaline__idp_endpoint }} - schedule-delay: {{ curaline__schedule_delay }} - file-busy-test-strategy: {{ curaline__file_busy_test_strategy }} + 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 }} - host: {{ curaline__cdr_api_host }} + 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 }} + base-path: {{ curaline__cdr_api_base_path | to_json }} credential-api: - scheme: {{ curaline__credential_api_scheme }} + scheme: {{ curaline__credential_api_scheme | to_json }} port: {{ curaline__credential_api_port }} - host: {{ curaline__credential_api_host }} - base-path: {{ curaline__credential_api_base_path }} + 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' %}