Files
infix/src/statd/python/yanger/ietf_system.py
T
Joachim Wiberg ffe5ed1e32 statd, show: fix several operational data reporting bugs
Three bugs found while testing modem support on hardware:

ietf_system.py: "interface": null written to DNS server entries when
  no interface is associated, causing LY_EVALID in the ietf-system
  schema validator.  Only include the key when the interface name is
  non-empty.

ietf_system.py: empty list inserts (user [], server [], options [],
  search []) overwrote configured data in the operational store instead
  of leaving it intact.  Guard each insert so operational data only
  shadows configured data when there is something to report.

show system: CPU temperature reported only the first sensor named
  exactly "cpu", "soc", or "core".  Platforms with multiple thermal
  zones (e.g. BPI-R4 exposes cpu and cpu1) always showed only one
  reading.  Collect all sensors whose name matches a CPU/SoC prefix
  and report the maximum.

statd.c: yanger parse errors logged without model name or libyang
  error string, making failures hard to diagnose.  Include both.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
2026-06-07 20:50:29 +02:00

465 lines
15 KiB
Python

import subprocess
import ipaddress
import re
from .common import insert,YangDate
from .host import HOST
def uboot_get_boot_order():
data = HOST.run_multiline("fw_printenv BOOT_ORDER".split(), [])
for line in data:
if "BOOT_ORDER" in line:
return line.strip().split("=")[1].split()
raise Exception
def grub_get_boot_order():
data = HOST.run_multiline("grub-editenv /mnt/aux/grub/grubenv list".split(), [])
for line in data:
if "ORDER" in line:
return line.split("=")[1].strip().split()
raise Exception
def get_boot_order():
order = None
try:
order = uboot_get_boot_order()
except:
pass
try:
if order is None:
order = grub_get_boot_order()
except:
pass
return order
def add_ntp(out):
"""Add NTP source information from chronyc sources.
The chronyc -c sources output is CSV with the following fields:
[0] Mode indicator:
^ server (we're a client to this source)
= peer (symmetric mode)
# local reference clock (GPS, PPS, etc.) - skipped, no IP address
[1] State indicator:
* selected (current sync source)
+ candidate
- outlier
? unusable
x falseticker
~ unstable
[2] Address (IP address or refclock name like "GPS")
[3] Stratum
[4] Poll interval (log2 seconds)
[5] Reach (octal reachability register)
[6] LastRx (seconds since last response)
[7] Last offset (seconds)
[8] Offset at last update (seconds)
[9] Error estimate (seconds)
"""
data = HOST.run_multiline(["chronyc", "-c", "sources"], [])
source = []
state_mode_map = {
"^": "server",
"=": "peer",
"#": "local-clock"
}
source_state_map = {
"*": "selected",
"+": "candidate",
"-": "outlier",
"?": "unusable",
"x": "falseticker",
"~": "unstable"
}
for line in data:
src = {}
line = line.split(',')
# Skip reference clocks (mode "#") as they have names like "GPS" instead of IP addresses
if line[0] == "#":
continue
src["address"] = line[2]
src["mode"] = state_mode_map[line[0]]
src["state"] = source_state_map[line[1]]
src["stratum"] = int(line[3])
src["poll"] = int(line[4])
source.append(src)
insert(out, "infix-system:ntp", "sources", "source", source)
def add_dns(out):
options = {}
servers = []
search = []
content = HOST.read_multiline("/etc/resolv.conf.head", [])
for line in content:
line = line.strip()
if line.startswith('nameserver'):
ip = line.split()[1]
try:
ipaddress.ip_address(ip)
servers.append({
"address": ip,
"origin": "static"
})
except ValueError:
continue
elif line.startswith('search'):
search.extend(line.split()[1:])
elif line.startswith('options'):
opts = line.split()[1:]
for opt in opts:
if opt.startswith('timeout:'):
options["timeout"] = int(opt.split(':')[1])
elif opt.startswith('attempts:'):
options["attempts"] = int(opt.split(':')[1])
output = HOST.run_multiline(['/sbin/resolvconf', '-l'], [])
for line in output:
line = line.strip()
if line.startswith('nameserver'):
parts = line.split('#', 1)
ip = parts[0].split()[1]
iface = None
if len(parts) > 1:
iface = parts[1].strip()
try:
ipaddress.ip_address(ip)
entry = {"address": ip, "origin": "dhcp"}
if iface:
entry["interface"] = iface
servers.append(entry)
except ValueError:
continue
elif line.startswith('search'):
parts = line.split('#', 1)
search.extend(parts[0].split()[1:])
if options:
insert(out, "infix-system:dns-resolver", "options", options)
if servers:
insert(out, "infix-system:dns-resolver", "server", servers)
if search:
insert(out, "infix-system:dns-resolver", "search", search)
def add_software_slots(out, data):
slots = []
for slot in data.get("slots", []):
for key, value in slot.items():
new = {}
new["name"] = key
new["bootname"] = slot[key].get("bootname")
new["class"] = slot[key].get("class")
new["state"] = slot[key].get("state")
new["bundle"] = {}
slot_status=value.get("slot_status", {})
if slot_status.get("bundle", {}).get("compatible"):
new["bundle"]["compatible"] = slot_status.get("bundle", {}).get("compatible")
if slot_status.get("bundle", {}).get("version"):
new["bundle"]["version"] = slot_status.get("bundle", {}).get("version")
if slot_status.get("checksum", {}).get("size"):
new["size"] = str(slot_status.get("checksum", {}).get("size"))
if slot_status.get("checksum", {}).get("sha256"):
new["sha256"] = slot_status.get("checksum", {}).get("sha256")
new["installed"] = {}
if slot_status.get("installed", {}).get("timestamp"):
new["installed"]["datetime"] = slot_status.get("installed", {}).get("timestamp")
if slot_status.get("installed", {}).get("count"):
new["installed"]["count"] = slot_status.get("installed", {}).get("count")
new["activated"] = {}
if slot_status.get("activated", {}).get("timestamp"):
new["activated"]["datetime"] = slot_status.get("activated", {}).get("timestamp")
if slot_status.get("activated", {}).get("count"):
new["activated"]["count"] = slot_status.get("activated", {}).get("count")
slots.append(new)
out["slot"] = slots
def add_platform(out):
platform = {}
pmap = {
"NAME": "os-name",
"VERSION_ID": "os-version",
"BUILD_ID": "os-release",
"ARCHITECTURE": "machine"
}
os_release = HOST.read("/etc/os-release")
for line in os_release.splitlines():
key, value = line.split('=')
name = pmap.get(key)
if name:
platform[name] = value.strip("\"")
insert(out, "platform", platform)
def add_services(out):
data = HOST.run_json(["initctl", "-j"], [])
services = []
for d in data:
try:
services.append({
"pid": d["pid"],
"name": d["identity"],
"status": d["status"],
"description": d["description"],
"statistics": {
"memory-usage": str(d.get("memory", 0)),
"uptime": str(d.get("uptime", 0)),
"restart-count": int(d.get("restarts", 0))
}
})
except KeyError:
continue
insert(out, "infix-system:services", "service", services)
def add_software(out):
software = {}
try:
data = HOST.run_json(["rauc", "status", "--detailed", "--output-format=json"], {})
software["compatible"] = data.get("compatible", "")
software["variant"] = data.get("variant", "")
software["booted"] = data.get("booted", "")
boot_order = get_boot_order()
if not boot_order is None:
software["boot-order"] = boot_order
add_software_slots(software, data)
except subprocess.CalledProcessError:
pass # Maybe an upgrade i progress, then rauc does not respond
installer = {}
installer_status = HOST.run_json(["rauc-installation-status"], {})
if installer_status.get("operation", {}):
installer["operation"] = installer_status["operation"]
if "progress" in installer_status:
progress = {}
if installer_status["progress"].get("percentage"):
progress["percentage"] = int(installer_status["progress"]["percentage"])
if installer_status["progress"].get("message"):
progress["message"] = installer_status["progress"]["message"]
installer["progress"] = progress
software["installer"] = installer
insert(out, "infix-system:software", software)
def add_hostname(out):
hostname = HOST.run(tuple(["hostname"]))
out["hostname"] = hostname.strip()
def add_timezone(out):
path = HOST.run(tuple("realpath /etc/localtime".split()), "")
timezone = None
prefixes = [
'/usr/share/zoneinfo/posix/',
'/usr/share/zoneinfo/right/',
'/usr/share/zoneinfo/'
]
for prefix in prefixes:
if path is not None and path.startswith(prefix):
timezone = path[len(prefix):]
break
if timezone is not None:
timezone=timezone.strip()
pattern = r'Etc/GMT([\+\-]\d{1,2})$'
match = re.search(pattern, timezone)
if match:
offset = -int(match.group(1))
insert(out, "clock", "timezone-utc-offset", offset)
else:
if timezone == "Etc/UTC":
insert(out, "clock", "timezone-utc-offset", 0)
else:
insert(out, "clock", "timezone-name", timezone)
def add_users(out):
# Map shell paths to YANG identity names
shell_map = {
"/bin/bash": "infix-system:bash",
"/bin/sh": "infix-system:sh",
"/usr/bin/clish": "infix-system:clish",
"/bin/false": "infix-system:false",
"/sbin/nologin": "infix-system:false",
"/usr/sbin/nologin": "infix-system:false",
}
# Get users from /etc/passwd - include users with 1000 <= uid < 10000 (added by confd)
passwd_output = HOST.run_multiline(["getent", "passwd"], [])
passwd_users = {}
for line in passwd_output:
parts = line.split(':')
if len(parts) >= 7:
username = parts[0]
uid = int(parts[2]) if parts[2].isdigit() else 0
shell = parts[6].strip()
if 1000 <= uid < 10000:
passwd_users[username] = shell_map.get(shell, "infix-system:false")
# Get password hashes from shadow
shadow_output = HOST.run_multiline(["getent", "shadow"], [])
shadow_hashes = {}
for line in shadow_output:
parts = line.split(':')
if len(parts) >= 2:
username = parts[0]
password_hash = parts[1]
# Only include valid password hashes (not locked/disabled)
if (password_hash and
not password_hash.startswith('*') and
not password_hash.startswith('!')):
shadow_hashes[username] = password_hash
# Build user list from passwd users (1000 <= uid < 10000)
users = []
for username, shell in passwd_users.items():
user = {"name": username}
if username in shadow_hashes:
user["password"] = shadow_hashes[username]
user["infix-system:shell"] = shell
# Read SSH authorized keys from /var/run/sshd/${user}.keys
keys_file = f"/var/run/sshd/{username}.keys"
keys_content = HOST.read(keys_file)
if keys_content:
authorized_keys = []
for line in keys_content.splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.split(None, 2)
if len(parts) >= 2:
algorithm = parts[0]
key_data = parts[1]
# Use comment as key name, or generate one
key_name = parts[2] if len(parts) > 2 else f"{username}-key-{len(authorized_keys)}"
authorized_keys.append({
"name": key_name,
"algorithm": algorithm,
"key-data": key_data
})
if authorized_keys:
user["authorized-key"] = authorized_keys
users.append(user)
if users:
insert(out, "authentication", "user", users)
def add_clock(out):
clock = {}
clock_now=YangDate()
uptime=HOST.read("/proc/uptime")
uptime = float(uptime.split()[0])
clock["boot-datetime"] = str(clock_now.from_seconds(uptime))
clock["current-datetime"] = str(clock_now)
insert(out, "clock", clock)
def add_resource_usage(out):
"""Add system resource usage (memory, load average, filesystem) to system-state"""
resource = {}
# Memory usage
try:
meminfo = HOST.read("/proc/meminfo")
if not meminfo:
return
mem_info = {}
for line in meminfo.splitlines():
parts = line.split(":")
if len(parts) == 2:
key = parts[0].strip()
value = parts[1].strip()
if key in ["MemTotal", "MemFree", "MemAvailable"]:
# Store in KiB (as provided by /proc/meminfo, mislabeled as kB)
mem_info[key] = int(value.split()[0])
if mem_info:
memory = {}
if "MemTotal" in mem_info:
memory["total"] = str(mem_info["MemTotal"])
if "MemFree" in mem_info:
memory["free"] = str(mem_info["MemFree"])
if "MemAvailable" in mem_info:
memory["available"] = str(mem_info["MemAvailable"])
resource["memory"] = memory
except (FileNotFoundError, ValueError):
pass
# Load average
try:
loadavg = HOST.read("/proc/loadavg")
load_parts = loadavg.strip().split()
if len(load_parts) >= 3:
load = {
"load-1min": load_parts[0],
"load-5min": load_parts[1],
"load-15min": load_parts[2]
}
resource["load-average"] = load
except (FileNotFoundError, ValueError):
pass
# Filesystem usage
filesystems = []
for mount in ["/", "/var", "/cfg"]:
try:
result = HOST.run_multiline(["df", "-k", mount], [])
if len(result) > 1:
parts = result[1].split()
if len(parts) >= 4:
filesystems.append({
"mount-point": mount,
"size": str(parts[1]),
"used": str(parts[2]),
"available": str(parts[3])
})
except (subprocess.CalledProcessError, ValueError, IndexError):
pass
if filesystems:
resource["filesystem"] = filesystems
if resource:
insert(out, "infix-system:resource-usage", resource)
def operational():
out = {
"ietf-system:system": {
},
"ietf-system:system-state": {
}
}
out_state = out["ietf-system:system-state"]
out_system = out["ietf-system:system"]
add_hostname(out_system)
add_users(out_system)
add_timezone(out_system)
add_software(out_state)
add_ntp(out_state)
add_dns(out_state)
add_clock(out_state)
add_platform(out_state)
add_services(out_state)
add_resource_usage(out_state)
return out