File: //usr/syno/bin/user.data.collector/synouserdata_availability
#!/usr/bin/env python3
# Copyright (c) 2000-2025 Synology Inc. All rights reserved.
from __future__ import annotations
import json
import os
import shutil
import sys
from contextlib import suppress
from enum import IntEnum
from pathlib import Path
from subprocess import DEVNULL, SubprocessError, run
COLLECTOR_VERSION_KEY = "collector_version"
COLLECTOR_VERSION = 1
STANDALONE = "standalone"
HA = "ha"
INTERVAL = "interval"
TEMP_AVA = "/tmp/synoavailability"
PERM_AVA = "/var/lib/availability/synoavailability"
REMOTE_TEMP_AVA = f"{TEMP_AVA}.remote_temp"
REMOTE_PERM_AVA = f"{TEMP_AVA}.remote_perm"
SYNOHA_BIN = "/usr/local/bin/synoha"
class Status(IntEnum):
DOWN = 0
UP = 1
NORMAL_STOP = 2
SPLIT_BRAIN = 3
TRANSITION = 4
UNKNOWN = -1
class Availability:
def __init__(self, ava_info: dict, interval: int) -> None:
self.interval = interval
self.last_check = int(ava_info["last_check"])
self.statuses = [
int(pair["status"])
for pair in ava_info.get("availabilities", [])
if isinstance(pair, dict) and "status" in pair and "count" in pair
for _ in range(int(pair["count"]))
]
self.min_count_limit = int(7 * 24 * 60 * 60 * 1_000_000_000 / interval) # 7 days of samples
def count(self, targets: list) -> int:
return sum(self.statuses.count(s) for s in targets)
def get(self) -> float | None:
if not self.statuses:
return None
up = [Status.UP]
down = [Status.DOWN, Status.NORMAL_STOP]
up_time = self.count(up)
total_time = self.count(up + down)
return up_time / total_time * 100.0 if total_time >= self.min_count_limit and total_time > 0 else None
def merge(self, other: Availability) -> None:
shift = (self.last_check - other.last_check) // self.interval
if shift != 0:
(other if shift > 0 else self).statuses.extend([Status.DOWN] * abs(shift))
diff = len(self.statuses) - len(other.statuses)
if diff != 0:
(other if diff > 0 else self).statuses[:0] = [Status.DOWN] * abs(diff)
self.statuses = [min(s1, s2) if s1 > 0 and s2 > 0 else max(s1, s2) for s1, s2 in zip(self.statuses, other.statuses)]
def has_volume() -> bool:
try:
result = run(["/usr/syno/sbin/synospace", "--enum", "--volume"], text=True, capture_output=True, check=True)
return any("<<< [" in line and "] >>>" in line for line in result.stdout.splitlines())
except SubprocessError:
return False
def is_ha_running() -> bool:
return run(["/usr/syno/sbin/synohacore", "--is_ha_running"], stdout=DEVNULL, stderr=DEVNULL, check=False).returncode == 0
def sync_file(src: str, dst: str) -> bool:
if not Path(SYNOHA_BIN).is_file() or src not in (TEMP_AVA, PERM_AVA) or dst not in (REMOTE_TEMP_AVA, REMOTE_PERM_AVA):
return False
return run([SYNOHA_BIN, "--sync-to-local", src, dst], stdout=DEVNULL, stderr=DEVNULL, check=False).returncode == 0
def load_temp_after_sync() -> dict | None:
try:
Path(PERM_AVA).parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(TEMP_AVA, PERM_AVA)
with Path(PERM_AVA).open("rb+") as f:
f.flush()
os.fsync(f.fileno())
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def load_perm() -> dict | None:
try:
with Path(PERM_AVA).open() as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def get_newest_file(files: list) -> str | None:
existing_files = {path: Path(path).stat().st_mtime for path in files if Path(path).is_file()}
return max(existing_files, key=existing_files.get, default=None)
def fetch_remote_info() -> str | None:
sync_file(TEMP_AVA, REMOTE_TEMP_AVA)
sync_file(PERM_AVA, REMOTE_PERM_AVA)
return get_newest_file([REMOTE_TEMP_AVA, REMOTE_PERM_AVA])
def get_availability() -> dict:
if not has_volume():
return {}
info = None
if Path(TEMP_AVA).is_file():
info = load_temp_after_sync()
elif Path(PERM_AVA).is_file():
info = load_perm()
if not info:
return {}
required_keys = [STANDALONE, HA, INTERVAL]
if not all(k in info for k in required_keys):
return {}
interval = int(info[INTERVAL])
standalone = Availability(info[STANDALONE], interval)
ha = Availability(info[HA], interval)
if is_ha_running() and (remote_path := fetch_remote_info()) is not None:
with Path(remote_path).open() as f:
remote_info = json.load(f)
if remote_info and all(k in remote_info for k in required_keys):
ha.merge(Availability(remote_info[HA], remote_info[INTERVAL]))
return {k: v for k, v in [(STANDALONE, standalone.get()), (HA, ha.get())] if v is not None}
def main() -> None:
availability_data = {COLLECTOR_VERSION_KEY: COLLECTOR_VERSION}
with suppress(Exception):
availability_data.update(get_availability())
sys.stdout.write(json.dumps(availability_data))
if __name__ == "__main__":
main()