diff --git a/doc/_quarto.yml b/doc/_quarto.yml index 90fdee39..b9bd75f0 100644 --- a/doc/_quarto.yml +++ b/doc/_quarto.yml @@ -76,6 +76,7 @@ website: contents: - ./nixos/guides/ca-gateway.md - ./nixos/guides/phoebus-alarm.md + - ./nixos/guides/phoebus-save-and-restore.md - section: Explanations - section: References contents: diff --git a/doc/nixos/guides/phoebus-save-and-restore.md b/doc/nixos/guides/phoebus-save-and-restore.md new file mode 100644 index 00000000..f9a264f4 --- /dev/null +++ b/doc/nixos/guides/phoebus-save-and-restore.md @@ -0,0 +1,50 @@ +--- +title: Phoebus Save-and-restore setup +--- + +The Phoebus Save-and-restore service is used by clients +to manage configuration and snapshots of PV values. +These snapshots can then be used by clients for comparison or for restoring PVs. + +This guide focuses on installing and configuring the Save-and-Restore service on a single server. + +For more details and documentation about Phoebus Save-and-Restore, +you can examine the [Save-and-restore official documentation]. + + [Save-and-restore official documentation]: https://control-system-studio.readthedocs.io/en/latest/services/save-and-restore/doc/index.html + +{{< include _pre-requisites.md >}} + +# Enabling the Phoebus Save-and-restore service + +To enable the Phoebus Save-and-restore service, +add this to your configuration: + +``` nix +{lib, ...}: { + services.phoebus-save-and-restore = { + enable = true; + openFirewall = true; + }; + + # Elasticsearch, needed by Phoebus Save-and-restore, is not free software (SSPL | Elastic License). + # To accept the license, add the code below: + nixpkgs.config.allowUnfreePredicate = pkg: + builtins.elem (lib.getName pkg) [ + "elasticsearch" + ]; +} +``` + +From the Phoebus graphical client side, +add this configuration + +``` ini +# Replace the IP address with your server's IP address or domain name +org.phoebus.applications.saveandrestore/jmasar.service.url=http://192.168.1.42:8080 +``` + +::: callout-warning +URLs for future versions of Phoebus Save-and-restore will need to change to: +`http://192.168.1.42:8080/save-restore` +::: diff --git a/nixos/module-list.nix b/nixos/module-list.nix index fadbdec0..af554d46 100644 --- a/nixos/module-list.nix +++ b/nixos/module-list.nix @@ -5,4 +5,5 @@ ./modules/phoebus/alarm-server.nix ./modules/phoebus/local-kafka.nix ./modules/phoebus/olog.nix + ./modules/phoebus/save-and-restore.nix ] diff --git a/nixos/modules/phoebus/alarm-logger.nix b/nixos/modules/phoebus/alarm-logger.nix index 8b39ed52..d8e49444 100644 --- a/nixos/modules/phoebus/alarm-logger.nix +++ b/nixos/modules/phoebus/alarm-logger.nix @@ -173,7 +173,7 @@ in { services.elasticsearch = lib.mkIf localElasticsearch { enable = true; - # Should be kept in sync with the phoebus-olog service + # Should be kept in sync with the phoebus-olog and phoebus-save-and-restore services package = pkgs.elasticsearch7; }; diff --git a/nixos/modules/phoebus/olog.nix b/nixos/modules/phoebus/olog.nix index 7ec9bb16..2b6dbb40 100644 --- a/nixos/modules/phoebus/olog.nix +++ b/nixos/modules/phoebus/olog.nix @@ -116,7 +116,7 @@ in { services.elasticsearch = { enable = true; - # Should be kept in sync with the phoebus-alarm-logger service + # Should be kept in sync with the phoebus-alarm-logger and phoebus-save-and-restore services package = pkgs.elasticsearch7; }; services.mongodb.enable = true; diff --git a/nixos/modules/phoebus/save-and-restore.nix b/nixos/modules/phoebus/save-and-restore.nix new file mode 100644 index 00000000..c3ee1dd3 --- /dev/null +++ b/nixos/modules/phoebus/save-and-restore.nix @@ -0,0 +1,155 @@ +{ + config, + epnixLib, + lib, + pkgs, + ... +}: let + cfg = config.services.phoebus-save-and-restore; + settingsFormat = pkgs.formats.javaProperties {}; + configFile = settingsFormat.generate "phoebus-save-and-restore.properties" cfg.settings; + + localElasticsearch = cfg.settings."elasticsearch.network.host" == "localhost"; +in { + options.services.phoebus-save-and-restore = { + enable = lib.mkEnableOption '' + the Phoebus Save-and-restore service. + + This service is used by clients + to manage configurations (aka save sets) and snapshots, + to compare snapshots, + and to restore PV values from snapshots. + ''; + + openFirewall = lib.mkOption { + description = '' + Open the firewall for the Phoebus Save-and-restore service. + + Warning: this opens the firewall on all network interfaces. + ''; + type = lib.types.bool; + default = false; + }; + + settings = lib.mkOption { + description = '' + Configuration for the Phoebus Save-and-restore service. + + These options will be put into a `.properties` file. + + Note that options containing a "." must be quoted. + + Available options can be seen here: + + ''; + default = {}; + type = lib.types.submodule { + freeformType = settingsFormat.type; + options = { + "server.port" = lib.mkOption { + description = "Port for the Save-and-restore service"; + type = lib.types.port; + default = 8080; + apply = toString; + }; + + "elasticsearch.network.host" = lib.mkOption { + description = '' + Elasticsearch server host + + If `localhost` (the default), + the Elasticsearch service will be automatically set up. + ''; + type = lib.types.str; + default = "localhost"; + }; + + "elasticsearch.http.port" = lib.mkOption { + description = "Elasticsearch server port"; + type = lib.types.port; + default = config.services.elasticsearch.port; + defaultText = lib.literalExpression "config.services.elasticsearch.port"; + apply = toString; + }; + }; + }; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.phoebus-save-and-restore = { + description = "Phoebus Save-and-restore"; + + wantedBy = ["multi-user.target"]; + after = lib.mkIf localElasticsearch ["elasticsearch.service"]; + + serviceConfig = { + ExecStart = "${pkgs.epnix.phoebus-save-and-restore}/bin/phoebus-save-and-restore --spring.config.location=file://${configFile}"; + Restart = "on-failure"; + DynamicUser = true; + + # Security options: + # --- + + # NETLINK needed to enumerate available interfaces + RestrictAddressFamilies = ["AF_INET" "AF_INET6"]; + # Service may not create new namespaces + RestrictNamespaces = true; + + # Service does not have access to other users + PrivateUsers = true; + # Service has no access to hardware devices + PrivateDevices = true; + + # Service cannot write to the hardware clock or system clock + ProtectClock = true; + # Service cannot modify the control group file system + ProtectControlGroups = true; + # Service has no access to home directories + ProtectHome = true; + # Service cannot change system host/domainname + ProtectHostname = true; + # Service cannot read from or write to the kernel log ring buffer + ProtectKernelLogs = true; + # Service cannot load or read kernel modules + ProtectKernelModules = true; + # Service cannot alter kernel tunables (/proc/sys, …) + ProtectKernelTunables = true; + # Service has restricted access to process tree (/proc hidepid=) + ProtectProc = "invisible"; + + # Service may not acquire new capabilities + CapabilityBoundingSet = ""; + # Service cannot change ABI personality + LockPersonality = true; + # Service has no access to non-process /proc files (/proc subset=) + ProcSubset = "pid"; + # Service may execute system calls only with native ABI + SystemCallArchitectures = "native"; + # Access write directories + UMask = "0077"; + # Service may create writable executable memory mappings + # This option isn't set due to the JVM marking some memory pages as executable + #MemoryDenyWriteExecute = true; + + # Service can only use a reasonable set of system calls, + # used by common system services + SystemCallFilter = ["@system-service"]; + # Disallowed system calls return EPERM instead of terminating the service + SystemCallErrorNumber = "EPERM"; + }; + }; + + services.elasticsearch = lib.mkIf localElasticsearch { + enable = true; + # Should be kept in sync with the phoebus-alarm-logger and phoebus-olog services + package = pkgs.elasticsearch7; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ + (lib.toInt cfg.settings."server.port") + ]; + }; + + meta.maintainers = with epnixLib.maintainers; [minijackson]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index cb605ef4..01289bc3 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -19,4 +19,5 @@ in { ca-gateway = handleTest ./ca-gateway.nix {}; phoebus-alarm = handleTest ./phoebus/alarm.nix {}; phoebus-olog = handleTest ./phoebus/olog.nix {}; + phoebus-save-and-restore = handleTest ./phoebus/save-and-restore.nix {}; } diff --git a/nixos/tests/phoebus/save-and-restore.nix b/nixos/tests/phoebus/save-and-restore.nix new file mode 100644 index 00000000..5ac36e6e --- /dev/null +++ b/nixos/tests/phoebus/save-and-restore.nix @@ -0,0 +1,32 @@ +{ + lib, + epnixLib, + ... +}: { + name = "phoebus-save-and-restore-simple-check"; + meta.maintainers = with epnixLib.maintainers; [minijackson]; + + nodes = { + server = { + services.phoebus-save-and-restore = { + enable = true; + openFirewall = true; + }; + + nixpkgs.config.allowUnfreePredicate = pkg: + builtins.elem (lib.getName pkg) [ + # Elasticsearch can be used as an SSPL-licensed software, which is + # not open-source. But as we're using it run tests, not exposing + # any service, this should be fine. + "elasticsearch" + ]; + + # Else OOM + virtualisation.memorySize = 2047; + }; + + client = {}; + }; + + testScript = builtins.readFile ./save-and-restore.py; +} diff --git a/nixos/tests/phoebus/save-and-restore.py b/nixos/tests/phoebus/save-and-restore.py new file mode 100644 index 00000000..1695a2e7 --- /dev/null +++ b/nixos/tests/phoebus/save-and-restore.py @@ -0,0 +1,163 @@ +import json +from typing import Any, Dict, List + +# Use Any here, instead of the recursive "JSON", +# because recursion is not yet supported +JSON = str | int | float | bool | None | Dict[str, Any] | List[Any] + +root_node_id = "44bef5de-e8e6-4014-af37-b8f6c8a939a2" +user = "myself" + + +def get(uri: str): + return json.loads( + client.succeed( + "curl -sSf " "-H 'Accept: application/json' " f"'http://server:8080{uri}'" + ) + ) + + +def put(uri: str, data: JSON): + encoded_data = json.dumps(data) + return json.loads( + client.succeed( + "curl -sSf " + "-X PUT " + "-H 'Content-Type: application/json' " + "-H 'Accept: application/json' " + f"'http://server:8080{uri}' " + f"--data '{encoded_data}'" + ) + ) + + +def delete(uri: str): + client.succeed( + "curl -sSf " + "-X DELETE " + "-H 'Content-Type: application/json' " + "-H 'Accept: application/json' " + f"'http://server:8080{uri}'" + ) + + +start_all() + +server.wait_for_unit("phoebus-save-and-restore.service") +server.wait_for_open_port(8080) + +client.wait_for_unit("multi-user.target") + +with subtest("Default root node is created"): + node = get(f"/node/{root_node_id}") + assert node["uniqueId"] == root_node_id + +subnode_id: str + +with subtest("We can create a subnode"): + result = put( + f"/node?parentNodeId={root_node_id}", + { + "name": "subnode", + "description": "A test subnode", + "userName": user, + }, + ) + subnode_id = result["uniqueId"] + # Check that it is really added + node = get(f"/node/{subnode_id}") + parent_node = get(f"/node/{subnode_id}/parent") + assert parent_node["uniqueId"] == root_node_id + +config_id: str + +with subtest("We can create a config"): + result = put( + f"/config?parentNodeId={subnode_id}", + { + "configurationNode": { + "name": "test configuration", + "userName": user, + }, + "configurationData": { + "pvList": [ + { + "pvName": "double", + }, + { + "pvName": "string", + }, + { + "pvName": "intarray", + }, + { + "pvName": "stringarray", + }, + { + "pvName": "enum", + }, + { + "pvName": "table", + }, + ] + }, + }, + ) + config_id = result["configurationNode"]["uniqueId"] + config = get(f"/config/{config_id}") + assert config["uniqueId"] == config_id + + +def vtype(name: str, typ: str, value: Any) -> Dict[str, Any]: + return { + "configPv": { + "pvName": name, + "readOnly": False, + }, + "value": { + "type": { + "name": typ, + "version": 1, + }, + "value": value, + "alarm": { + "severity": "NONE", + "status": "NONE", + "name": "NO_ALARM", + }, + "time": {"unixSec": 1664550284, "nanoSec": 870687555}, + "display": { + "lowDisplay": 0.0, + "highDisplay": 0.0, + "units": "", + }, + }, + } + + +snapshot_id: str + +with subtest("We can create a snapshot"): + result = put( + f"/snapshot?parentNodeId={config_id}", + { + "snapshotNode": { + "name": "test snapshot", + "userName": user, + }, + "snapshotData": { + "snapshotItems": [ + vtype("double", "VDouble", 42.0), + vtype("string", "VString", "hello"), + vtype("intarray", "VIntArray", [1, 2, 3]), + vtype("stringarray", "VStringArray", ["you", "and", "me"]), + ], + }, + }, + ) + snapshot_id = result["snapshotNode"]["uniqueId"] + snapshot = get(f"/snapshot/{snapshot_id}") + assert config["uniqueId"] == config_id + +with subtest("We can delete a node"): + print(delete(f"/node/{subnode_id}"))