replacing signal cli rest api with signal cli #89
@@ -0,0 +1,46 @@
|
|||||||
|
{ pkgs, ... }:
|
||||||
|
{
|
||||||
|
environment.systemPackages = [
|
||||||
|
pkgs.signal-cli
|
||||||
|
];
|
||||||
|
|
||||||
|
users = {
|
||||||
|
groups.signal-cli = { };
|
||||||
|
users.signal-cli = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "signal-cli";
|
||||||
|
home = "/var/lib/signal-cli";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /var/lib/signal-cli 0700 signal-cli signal-cli - -"
|
||||||
|
"Z /var/lib/signal-cli - signal-cli signal-cli - -"
|
||||||
|
];
|
||||||
|
|
||||||
|
systemd.services.signal-cli = {
|
||||||
|
description = "Signal CLI JSON-RPC service";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
unitConfig.RequiresMountsFor = [ "/var/lib/signal-cli" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = "signal-cli";
|
||||||
|
Group = "signal-cli";
|
||||||
|
ExecStart = "${pkgs.signal-cli}/bin/signal-cli --data-dir /var/lib/signal-cli daemon --socket /run/signal-cli/socket";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = "5s";
|
||||||
|
SuccessExitStatus = 143;
|
||||||
|
RuntimeDirectory = "signal-cli";
|
||||||
|
RuntimeDirectoryMode = "0750";
|
||||||
|
UMask = "0007";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ReadWritePaths = [ "/var/lib/signal-cli" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,11 +7,7 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.snapshot_manager;
|
cfg = config.services.snapshot_manager;
|
||||||
snapshotManagerPackages =
|
snapshotManagerPackages = ps: with ps; [ typer ];
|
||||||
ps: with ps; [
|
|
||||||
httpx
|
|
||||||
typer
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
|
|||||||
+48
-34
@@ -2,49 +2,63 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from os import getenv
|
import socket
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SIGNAL_API_URL = "http://localhost:8989/v2/send"
|
|
||||||
SIGNAL_API_TIMEOUT = 4.0
|
class SignalRPCError(RuntimeError):
|
||||||
|
"""signal-cli returned an error or closed without responding."""
|
||||||
|
|
||||||
|
|
||||||
def signal_alert(body: str, title: str = "") -> None:
|
def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float = 4.0) -> None:
|
||||||
"""Send a signal alert.
|
"""Send a Signal alert through the local signal-cli daemon.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body (str): The body of the alert.
|
body: The body of the alert.
|
||||||
title (str, optional): The title of the alert. Defaults to "".
|
from_phone: The Signal account sending the alert.
|
||||||
"""
|
to_phone: The Signal account receiving the alert.
|
||||||
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
|
timeout: Seconds to wait on each socket operation.
|
||||||
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
|
|
||||||
if not from_phone or not to_phone:
|
|
||||||
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Apprise's Signal integration did not support titles, so preserve that behavior.
|
Raises:
|
||||||
if title:
|
SignalRPCError: If signal-cli returns an error or closes the
|
||||||
logger.debug("Signal does not support notification titles; ignoring title")
|
connection before responding.
|
||||||
|
OSError: If the socket is unreachable or an operation times out.
|
||||||
|
"""
|
||||||
|
signal_rpc_id = "signal-alert"
|
||||||
|
request = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "send",
|
||||||
|
"params": {
|
||||||
|
"account": from_phone,
|
||||||
|
"recipient": [to_phone],
|
||||||
|
"message": body,
|
||||||
|
},
|
||||||
|
"id": signal_rpc_id,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||||
SIGNAL_API_URL,
|
client.settimeout(timeout)
|
||||||
json={
|
client.connect("/run/signal-cli/socket")
|
||||||
"message": body,
|
client.sendall(json.dumps(request).encode() + b"\n")
|
||||||
"number": from_phone,
|
|
||||||
"recipients": [to_phone],
|
|
||||||
"text_mode": "normal",
|
|
||||||
},
|
|
||||||
timeout=SIGNAL_API_TIMEOUT,
|
|
||||||
follow_redirects=True,
|
|
||||||
)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
logger.exception("Unable to contact the Signal API")
|
|
||||||
return
|
|
||||||
|
|
||||||
if response.status_code not in {httpx.codes.OK, httpx.codes.CREATED}:
|
buffer = b""
|
||||||
logger.error("Signal API returned HTTP status %d", response.status_code)
|
while chunk := client.recv(65536):
|
||||||
|
buffer += chunk
|
||||||
|
while b"\n" in buffer:
|
||||||
|
line, buffer = buffer.split(b"\n", maxsplit=1)
|
||||||
|
response = json.loads(line)
|
||||||
|
if response.get("id") != signal_rpc_id:
|
||||||
|
continue
|
||||||
|
if rpc_error := response.get("error"):
|
||||||
|
error = f"Signal RPC returned an error: {rpc_error}"
|
||||||
|
raise SignalRPCError(error)
|
||||||
|
return
|
||||||
|
error = "Signal RPC socket closed before returning a response"
|
||||||
|
raise SignalRPCError(error)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
error = f"Signal RPC failed: {exc!r}"
|
||||||
|
raise SignalRPCError(error) from exc
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
from functools import cache
|
from functools import cache
|
||||||
|
from os import getenv
|
||||||
from pathlib import Path # noqa: TC003 This is required for the typer CLI
|
from pathlib import Path # noqa: TC003 This is required for the typer CLI
|
||||||
from re import compile as re_compile
|
from re import compile as re_compile
|
||||||
from re import search
|
from re import search
|
||||||
@@ -24,6 +25,15 @@ def main(config_file: Path) -> None:
|
|||||||
configure_logger(level="DEBUG")
|
configure_logger(level="DEBUG")
|
||||||
logger.info("Starting snapshot_manager")
|
logger.info("Starting snapshot_manager")
|
||||||
|
|
||||||
|
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
|
||||||
|
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
|
||||||
|
if not from_phone:
|
||||||
|
error = "SIGNAL_ALERT_FROM_PHONE environment variable is not set"
|
||||||
|
raise ValueError(error)
|
||||||
|
if not to_phone:
|
||||||
|
error = "SIGNAL_ALERT_TO_PHONE environment variable is not set"
|
||||||
|
raise ValueError(error)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
time_stamp = get_time_stamp()
|
time_stamp = get_time_stamp()
|
||||||
|
|
||||||
@@ -33,14 +43,14 @@ def main(config_file: Path) -> None:
|
|||||||
if status != "snapshot created":
|
if status != "snapshot created":
|
||||||
msg = f"{dataset.name} failed to create snapshot {time_stamp}"
|
msg = f"{dataset.name} failed to create snapshot {time_stamp}"
|
||||||
logger.error(msg)
|
logger.error(msg)
|
||||||
signal_alert(msg)
|
signal_alert(msg, from_phone, to_phone)
|
||||||
continue
|
continue
|
||||||
count_lookup = get_count_lookup(config_file, dataset.name)
|
count_lookup = get_count_lookup(config_file, dataset.name)
|
||||||
logger.info(f"using {count_lookup} for {dataset.name}")
|
logger.info(f"using {count_lookup} for {dataset.name}")
|
||||||
get_snapshots_to_delete(dataset, count_lookup)
|
get_snapshots_to_delete(dataset, count_lookup, from_phone, to_phone)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("snapshot_manager failed")
|
logger.exception("snapshot_manager failed")
|
||||||
signal_alert("snapshot_manager failed")
|
signal_alert("snapshot_manager failed", from_phone, to_phone)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
logger.info("snapshot_manager completed")
|
logger.info("snapshot_manager completed")
|
||||||
@@ -92,12 +102,16 @@ def load_config_data(config_file: Path) -> dict[str, dict[str, int]]:
|
|||||||
def get_snapshots_to_delete(
|
def get_snapshots_to_delete(
|
||||||
dataset: Dataset,
|
dataset: Dataset,
|
||||||
count_lookup: dict[str, int],
|
count_lookup: dict[str, int],
|
||||||
|
from_phone: str,
|
||||||
|
to_phone: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Get snapshots to delete.
|
"""Get snapshots to delete.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
dataset (Dataset): the dataset
|
dataset (Dataset): the dataset
|
||||||
count_lookup (dict[str, int]): the count lookup
|
count_lookup (dict[str, int]): the count lookup
|
||||||
|
from_phone (str): The Signal account sending alerts.
|
||||||
|
to_phone (str): The Signal account receiving alerts.
|
||||||
"""
|
"""
|
||||||
snapshots = dataset.get_snapshots()
|
snapshots = dataset.get_snapshots()
|
||||||
|
|
||||||
@@ -127,7 +141,7 @@ def get_snapshots_to_delete(
|
|||||||
for snapshot in snapshots_being_deleted:
|
for snapshot in snapshots_being_deleted:
|
||||||
if error := dataset.delete_snapshot(snapshot):
|
if error := dataset.delete_snapshot(snapshot):
|
||||||
error_message = f"{dataset.name}@{snapshot} failed to delete: {error}"
|
error_message = f"{dataset.name}@{snapshot} failed to delete: {error}"
|
||||||
signal_alert(error_message)
|
signal_alert(error_message, from_phone, to_phone)
|
||||||
logger.error(error_message)
|
logger.error(error_message)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Executable
+37
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
if [[ "$(hostname)" != "jeeves" ]]; then
|
||||||
|
echo "Signal device authorization must run on jeeves." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$EUID" -ne 0 ]]; then
|
||||||
|
echo "Signal device authorization must run as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$#" -ne 1 ]]; then
|
||||||
|
echo "Usage: signal_authorize.sh DEVICE_URI" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$1" != sgnl://linkdevice\?* ]]; then
|
||||||
|
echo "Invalid Signal device link URI." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
service_name="signal-cli"
|
||||||
|
data_dir="/var/lib/signal-cli"
|
||||||
|
device_uri="$1"
|
||||||
|
restart_service=false
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ "$restart_service" == true ]]; then
|
||||||
|
systemctl start "$service_name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
restart_service=true
|
||||||
|
systemctl stop "$service_name"
|
||||||
|
runuser -u signal-cli -- \
|
||||||
|
signal-cli --data-dir "$data_dir" -a +12016554378 addDevice --uri "$device_uri"
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
host_name="$(hostname)"
|
||||||
|
if [[ "$#" -ne 0 ]]; then
|
||||||
|
echo "Usage: signal_link.sh" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
service_name="signal-cli"
|
||||||
|
data_dir="/var/lib/signal-cli"
|
||||||
|
link_pid=""
|
||||||
|
temp_dir=""
|
||||||
|
restart_service=false
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "$link_pid" ]] && kill -0 "$link_pid" 2>/dev/null; then
|
||||||
|
kill "$link_pid" 2>/dev/null || true
|
||||||
|
wait "$link_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$temp_dir" ]]; then
|
||||||
|
rm -f -- "$temp_dir/link-uri"
|
||||||
|
rmdir -- "$temp_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$restart_service" == true ]]; then
|
||||||
|
sudo systemctl start "$service_name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
restart_service=true
|
||||||
|
sudo systemctl stop "$service_name"
|
||||||
|
|
||||||
|
temp_dir="$(mktemp -d)"
|
||||||
|
link_fifo="$temp_dir/link-uri"
|
||||||
|
mkfifo "$link_fifo"
|
||||||
|
|
||||||
|
sudo -u signal-cli \
|
||||||
|
signal-cli --data-dir "$data_dir" link --name "$host_name" >"$link_fifo" &
|
||||||
|
link_pid="$!"
|
||||||
|
|
||||||
|
if ! IFS= read -r device_uri <"$link_fifo"; then
|
||||||
|
wait "$link_pid"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$device_uri" != sgnl://linkdevice\?* ]]; then
|
||||||
|
echo "signal-cli returned an invalid device link URI." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Run this command manually on jeeves in another dotfiles dir:"
|
||||||
|
printf "sudo ./scripts/signal/authorize.sh %q\n" "$device_uri"
|
||||||
|
echo "Waiting for jeeves to authorize this device..."
|
||||||
|
|
||||||
|
wait "$link_pid"
|
||||||
|
link_pid=""
|
||||||
|
echo "$host_name is now linked to the Signal account on jeeves."
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"${inputs.self}/common/optional/monitoring-agent.nix"
|
"${inputs.self}/common/optional/monitoring-agent.nix"
|
||||||
"${inputs.self}/common/optional/nvidia.nix"
|
"${inputs.self}/common/optional/nvidia.nix"
|
||||||
"${inputs.self}/common/optional/scanner.nix"
|
"${inputs.self}/common/optional/scanner.nix"
|
||||||
|
"${inputs.self}/common/optional/signal-cli.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/systemd-boot.nix"
|
"${inputs.self}/common/optional/systemd-boot.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"${inputs.self}/users/richie"
|
"${inputs.self}/users/richie"
|
||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
|
"${inputs.self}/common/optional/signal-cli.nix"
|
||||||
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/systemd-boot.nix"
|
"${inputs.self}/common/optional/systemd-boot.nix"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ in
|
|||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
"${inputs.self}/common/optional/monitoring-agent.nix"
|
"${inputs.self}/common/optional/monitoring-agent.nix"
|
||||||
|
"${inputs.self}/common/optional/signal-cli.nix"
|
||||||
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
"${inputs.self}/common/optional/ssh_decrypt.nix"
|
||||||
"${inputs.self}/common/optional/syncthing_base.nix"
|
"${inputs.self}/common/optional/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
let
|
|
||||||
vars = import ../vars.nix;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
virtualisation.oci-containers.containers.signal_cli_rest_api = {
|
|
||||||
image = "bbernhard/signal-cli-rest-api:0.199-dev";
|
|
||||||
ports = [
|
|
||||||
"8989:8080"
|
|
||||||
];
|
|
||||||
volumes = [
|
|
||||||
"${vars.docker_configs}/signal-cli-config:/home/.local/share/signal-cli"
|
|
||||||
];
|
|
||||||
environment = {
|
|
||||||
MODE = "json-rpc";
|
|
||||||
};
|
|
||||||
autoStart = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ let
|
|||||||
open_webui = 8080;
|
open_webui = 8080;
|
||||||
postgresql = 5432;
|
postgresql = 5432;
|
||||||
share = 8091;
|
share = 8091;
|
||||||
signal_cli = 8989;
|
|
||||||
syncthing = 8384;
|
syncthing = 8384;
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
@@ -40,7 +39,6 @@ in
|
|||||||
ports.ollama
|
ports.ollama
|
||||||
ports.open_webui
|
ports.open_webui
|
||||||
ports.postgresql
|
ports.postgresql
|
||||||
ports.signal_cli
|
|
||||||
ports.syncthing
|
ports.syncthing
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
inputs.disko.nixosModules.disko
|
inputs.disko.nixosModules.disko
|
||||||
"${inputs.self}/users/richie"
|
"${inputs.self}/users/richie"
|
||||||
"${inputs.self}/common/global"
|
"${inputs.self}/common/global"
|
||||||
|
"${inputs.self}/common/optional/signal-cli.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
"${inputs.self}/common/optional/x86-64-v3"
|
"${inputs.self}/common/optional/x86-64-v3"
|
||||||
./disk-config.nix
|
./disk-config.nix
|
||||||
|
|||||||
+40
-52
@@ -2,80 +2,68 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from os import environ
|
import json
|
||||||
|
import socket
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import httpx
|
import pytest
|
||||||
|
|
||||||
from python.signal_alert import signal_alert
|
from python.signal_alert import SignalRPCError, signal_alert
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert(mocker: MockerFixture) -> None:
|
def test_signal_alert(mocker: MockerFixture) -> None:
|
||||||
"""test_signal_alert."""
|
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.CREATED)
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
mock_post = mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
|
client.recv.side_effect = [b'{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n']
|
||||||
|
|
||||||
signal_alert("test")
|
assert signal_alert("test", "1234567890", "0987654321") is None
|
||||||
|
|
||||||
mock_logger.info.assert_not_called()
|
mock_logger.info.assert_not_called()
|
||||||
mock_post.assert_called_once_with(
|
mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
"http://localhost:8989/v2/send",
|
client.settimeout.assert_called_once_with(4.0)
|
||||||
json={
|
client.connect.assert_called_once_with("/run/signal-cli/socket")
|
||||||
|
request = json.loads(client.sendall.call_args.args[0])
|
||||||
|
assert request == {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "send",
|
||||||
|
"params": {
|
||||||
|
"account": "1234567890",
|
||||||
|
"recipient": ["0987654321"],
|
||||||
"message": "test",
|
"message": "test",
|
||||||
"number": "1234567890",
|
|
||||||
"recipients": ["0987654321"],
|
|
||||||
"text_mode": "normal",
|
|
||||||
},
|
},
|
||||||
timeout=4.0,
|
"id": "signal-alert",
|
||||||
follow_redirects=True,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
|
def test_signal_alert_socket_error(mocker: MockerFixture) -> None:
|
||||||
"""test_signal_alert_no_phones."""
|
mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed"))
|
||||||
if "SIGNAL_ALERT_FROM_PHONE" in environ:
|
|
||||||
del environ["SIGNAL_ALERT_FROM_PHONE"]
|
|
||||||
if "SIGNAL_ALERT_TO_PHONE" in environ:
|
|
||||||
del environ["SIGNAL_ALERT_TO_PHONE"]
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
|
||||||
signal_alert("test")
|
|
||||||
|
|
||||||
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
|
with pytest.raises(SignalRPCError, match="Signal RPC failed") as exc_info:
|
||||||
|
signal_alert("test", "1234567890", "0987654321")
|
||||||
|
|
||||||
|
assert isinstance(exc_info.value.__cause__, ConnectionError)
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_http_error(mocker: MockerFixture) -> None:
|
def test_signal_alert_rpc_error(mocker: MockerFixture) -> None:
|
||||||
"""HTTP errors are logged rather than propagated."""
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
client.recv.side_effect = [b'{"jsonrpc":"2.0","error":{"code":-1,"message":"failed"},"id":"signal-alert"}\n']
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
with pytest.raises(SignalRPCError, match=r"Signal RPC returned an error:.*failed"):
|
||||||
request = httpx.Request("POST", "http://localhost:8989/v2/send")
|
signal_alert("test", "1234567890", "0987654321")
|
||||||
mocker.patch(
|
|
||||||
"python.signal_alert.httpx.post",
|
|
||||||
side_effect=httpx.ConnectError("connection failed", request=request),
|
|
||||||
)
|
|
||||||
|
|
||||||
signal_alert("test")
|
|
||||||
|
|
||||||
mock_logger.exception.assert_called_once_with("Unable to contact the Signal API")
|
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_unsuccessful_response(mocker: MockerFixture) -> None:
|
def test_signal_alert_ignores_notifications(mocker: MockerFixture) -> None:
|
||||||
"""Unexpected response statuses are logged."""
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
client.recv.side_effect = [
|
||||||
|
b'{"jsonrpc":"2.0","method":"receive"}\n{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n'
|
||||||
|
]
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
assert signal_alert("test", "1234567890", "0987654321") is None
|
||||||
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.BAD_GATEWAY)
|
|
||||||
mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
|
|
||||||
|
|
||||||
signal_alert("test")
|
assert client.recv.call_count == 1
|
||||||
|
|
||||||
mock_logger.error.assert_called_once_with("Signal API returned HTTP status %d", httpx.codes.BAD_GATEWAY)
|
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ if TYPE_CHECKING:
|
|||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
SNAPSHOT_MANAGER = "python.tools.snapshot_manager"
|
SNAPSHOT_MANAGER = "python.tools.snapshot_manager"
|
||||||
|
FROM_PHONE = "1234567890"
|
||||||
|
TO_PHONE = "0987654321"
|
||||||
|
|
||||||
|
|
||||||
|
def patch_phone_numbers(mocker: MockerFixture) -> None:
|
||||||
|
"""Patch the Signal phone number environment variables."""
|
||||||
|
phone_numbers = {
|
||||||
|
"SIGNAL_ALERT_FROM_PHONE": FROM_PHONE,
|
||||||
|
"SIGNAL_ALERT_TO_PHONE": TO_PHONE,
|
||||||
|
}
|
||||||
|
mocker.patch(f"{SNAPSHOT_MANAGER}.getenv", side_effect=phone_numbers.get)
|
||||||
|
|
||||||
|
|
||||||
def patch_utcnow(mocker: MockerFixture, datetime_value: datetime) -> None:
|
def patch_utcnow(mocker: MockerFixture, datetime_value: datetime) -> None:
|
||||||
@@ -34,6 +45,7 @@ def create_mock_snapshot(mocker: MockerFixture, name: str) -> Snapshot:
|
|||||||
def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
"""Test main."""
|
"""Test main."""
|
||||||
load_config_data.cache_clear()
|
load_config_data.cache_clear()
|
||||||
|
patch_phone_numbers(mocker)
|
||||||
|
|
||||||
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
@@ -58,12 +70,15 @@ def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
|||||||
"daily": 0,
|
"daily": 0,
|
||||||
"monthly": 0,
|
"monthly": 0,
|
||||||
},
|
},
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
"""Test main."""
|
"""Test main."""
|
||||||
load_config_data.cache_clear()
|
load_config_data.cache_clear()
|
||||||
|
patch_phone_numbers(mocker)
|
||||||
|
|
||||||
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
@@ -78,7 +93,11 @@ def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem)
|
|||||||
fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml)
|
fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml)
|
||||||
main(Path("/mock_snapshot_config.toml"))
|
main(Path("/mock_snapshot_config.toml"))
|
||||||
|
|
||||||
mock_signal_alert.assert_called_once_with("test_dataset failed to create snapshot 2023-01-01T00:00:00")
|
mock_signal_alert.assert_called_once_with(
|
||||||
|
"test_dataset failed to create snapshot 2023-01-01T00:00:00",
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
|
)
|
||||||
mock_get_datasets.assert_called_once()
|
mock_get_datasets.assert_called_once()
|
||||||
mock_get_snapshots_to_delete.assert_not_called()
|
mock_get_snapshots_to_delete.assert_not_called()
|
||||||
|
|
||||||
@@ -86,6 +105,7 @@ def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem)
|
|||||||
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
||||||
"""Test main."""
|
"""Test main."""
|
||||||
load_config_data.cache_clear()
|
load_config_data.cache_clear()
|
||||||
|
patch_phone_numbers(mocker)
|
||||||
|
|
||||||
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00")
|
||||||
|
|
||||||
@@ -103,7 +123,7 @@ def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None:
|
|||||||
|
|
||||||
assert isinstance(pytest_wrapped_e.value, SystemExit)
|
assert isinstance(pytest_wrapped_e.value, SystemExit)
|
||||||
assert pytest_wrapped_e.value.code == 1
|
assert pytest_wrapped_e.value.code == 1
|
||||||
mock_signal_alert.assert_called_once_with("snapshot_manager failed")
|
mock_signal_alert.assert_called_once_with("snapshot_manager failed", FROM_PHONE, TO_PHONE)
|
||||||
mock_get_datasets.assert_called_once()
|
mock_get_datasets.assert_called_once()
|
||||||
mock_get_snapshots_to_delete.assert_not_called()
|
mock_get_snapshots_to_delete.assert_not_called()
|
||||||
|
|
||||||
@@ -120,7 +140,12 @@ def test_get_snapshots_to_delete(mocker: MockerFixture) -> None:
|
|||||||
|
|
||||||
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
get_snapshots_to_delete(
|
||||||
|
mock_dataset,
|
||||||
|
{"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0},
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
|
)
|
||||||
|
|
||||||
mock_signal_alert.assert_not_called()
|
mock_signal_alert.assert_not_called()
|
||||||
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
||||||
@@ -135,7 +160,12 @@ def test_get_snapshots_to_delete_no_snapshot(mocker: MockerFixture) -> None:
|
|||||||
|
|
||||||
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
get_snapshots_to_delete(
|
||||||
|
mock_dataset,
|
||||||
|
{"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0},
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
|
)
|
||||||
|
|
||||||
mock_signal_alert.assert_not_called()
|
mock_signal_alert.assert_not_called()
|
||||||
mock_dataset.delete_snapshot.assert_not_called()
|
mock_dataset.delete_snapshot.assert_not_called()
|
||||||
@@ -153,10 +183,17 @@ def test_get_snapshots_to_delete_errored(mocker: MockerFixture) -> None:
|
|||||||
|
|
||||||
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert")
|
||||||
|
|
||||||
get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0})
|
get_snapshots_to_delete(
|
||||||
|
mock_dataset,
|
||||||
|
{"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0},
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
|
)
|
||||||
|
|
||||||
mock_signal_alert.assert_called_once_with(
|
mock_signal_alert.assert_called_once_with(
|
||||||
"test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones"
|
"test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones",
|
||||||
|
FROM_PHONE,
|
||||||
|
TO_PHONE,
|
||||||
)
|
)
|
||||||
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415")
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ in
|
|||||||
"ollama"
|
"ollama"
|
||||||
"plugdev"
|
"plugdev"
|
||||||
"scanner"
|
"scanner"
|
||||||
|
"signal-cli"
|
||||||
"transmission"
|
"transmission"
|
||||||
"uaccess"
|
"uaccess"
|
||||||
"uucp"
|
"uucp"
|
||||||
|
|||||||
Reference in New Issue
Block a user