HEX
Server: Apache/2.4.63 (Unix)
System: Linux TOMS-220NAS 4.4.302+ #86009 SMP Wed Nov 26 18:19:17 CST 2025 x86_64
User: flavio87 (1026)
PHP: 8.3.27
Disabled: NONE
Upload Files
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()