Skip to content

Commit 2324b70

Browse files
authored
Storage space usage API (#6046)
* Storage space usage API * Move to host API * add tests * fix test url * more tests * fix tests * fix test * PR comments * update test * tweak format and url * add .DS_Store to .gitignore * update tests * test coverage * update to new struct * update test
1 parent 43f20fe commit 2324b70

File tree

7 files changed

+746
-3
lines changed

7 files changed

+746
-3
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,6 @@ ENV/
100100
# mypy
101101
/.mypy_cache/*
102102
/.dmypy.json
103+
104+
# Mac
105+
.DS_Store

supervisor/api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def _register_host(self) -> None:
198198
web.post("/host/reload", api_host.reload),
199199
web.post("/host/options", api_host.options),
200200
web.get("/host/services", api_host.services),
201+
web.get("/host/disks/default/usage", api_host.disk_usage),
201202
]
202203
)
203204

supervisor/api/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
ATTR_LOCAL_ONLY = "local_only"
5050
ATTR_LOCATION_ATTRIBUTES = "location_attributes"
5151
ATTR_LOCATIONS = "locations"
52+
ATTR_MAX_DEPTH = "max_depth"
5253
ATTR_MDNS = "mdns"
5354
ATTR_MODEL = "model"
5455
ATTR_MOUNTS = "mounts"

supervisor/api/host.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
ATTR_FORCE,
5252
ATTR_IDENTIFIERS,
5353
ATTR_LLMNR_HOSTNAME,
54+
ATTR_MAX_DEPTH,
5455
ATTR_STARTUP_TIME,
5556
ATTR_USE_NTP,
5657
ATTR_VIRTUALIZATION,
@@ -289,3 +290,49 @@ async def advanced_logs(
289290
) -> web.StreamResponse:
290291
"""Return systemd-journald logs. Wrapped as standard API handler."""
291292
return await self.advanced_logs_handler(request, identifier, follow)
293+
294+
@api_process
295+
async def disk_usage(self, request: web.Request) -> dict:
296+
"""Return a breakdown of storage usage for the system."""
297+
298+
max_depth = request.query.get(ATTR_MAX_DEPTH, 1)
299+
try:
300+
max_depth = int(max_depth)
301+
except ValueError:
302+
max_depth = 1
303+
304+
disk = self.sys_hardware.disk
305+
306+
total, used, _ = await self.sys_run_in_executor(
307+
disk.disk_usage, self.sys_config.path_supervisor
308+
)
309+
310+
known_paths = await self.sys_run_in_executor(
311+
disk.get_dir_sizes,
312+
{
313+
"addons_data": self.sys_config.path_addons_data,
314+
"addons_config": self.sys_config.path_addon_configs,
315+
"media": self.sys_config.path_media,
316+
"share": self.sys_config.path_share,
317+
"backup": self.sys_config.path_backup,
318+
"ssl": self.sys_config.path_ssl,
319+
"homeassistant": self.sys_config.path_homeassistant,
320+
},
321+
max_depth,
322+
)
323+
return {
324+
# this can be the disk/partition ID in the future
325+
"id": "root",
326+
"label": "Root",
327+
"total_bytes": total,
328+
"used_bytes": used,
329+
"children": [
330+
{
331+
"id": "system",
332+
"label": "System",
333+
"used_bytes": used
334+
- sum(path["used_bytes"] for path in known_paths),
335+
},
336+
*known_paths,
337+
],
338+
}

supervisor/hardware/disk.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
"""Read disk hardware info from system."""
22

3+
import errno
34
import logging
45
from pathlib import Path
56
import shutil
7+
from typing import Any
8+
9+
from supervisor.resolution.const import UnhealthyReason
610

711
from ..coresys import CoreSys, CoreSysAttributes
812
from ..exceptions import DBusError, DBusObjectError, HardwareNotFound
@@ -54,25 +58,100 @@ def get_disk_total_space(self, path: str | Path) -> float:
5458
5559
Must be run in executor.
5660
"""
57-
total, _, _ = shutil.disk_usage(path)
61+
total, _, _ = self.disk_usage(path)
5862
return round(total / (1024.0**3), 1)
5963

6064
def get_disk_used_space(self, path: str | Path) -> float:
6165
"""Return used space (GiB) on disk for path.
6266
6367
Must be run in executor.
6468
"""
65-
_, used, _ = shutil.disk_usage(path)
69+
_, used, _ = self.disk_usage(path)
6670
return round(used / (1024.0**3), 1)
6771

6872
def get_disk_free_space(self, path: str | Path) -> float:
6973
"""Return free space (GiB) on disk for path.
7074
7175
Must be run in executor.
7276
"""
73-
_, _, free = shutil.disk_usage(path)
77+
_, _, free = self.disk_usage(path)
7478
return round(free / (1024.0**3), 1)
7579

80+
def disk_usage(self, path: str | Path) -> tuple[int, int, int]:
81+
"""Return (total, used, free) in bytes for path.
82+
83+
Must be run in executor.
84+
"""
85+
return shutil.disk_usage(path)
86+
87+
def get_dir_structure_sizes(self, path: Path, max_depth: int = 1) -> dict[str, Any]:
88+
"""Return a recursive dict of subdirectories and their sizes, only if size > 0.
89+
90+
Excludes external mounts and symlinks to avoid counting files on other filesystems
91+
or following symlinks that could lead to infinite loops or incorrect sizes.
92+
"""
93+
94+
size = 0
95+
if not path.exists():
96+
return {"used_bytes": size}
97+
98+
children: list[dict[str, Any]] = []
99+
root_device = path.stat().st_dev
100+
101+
for child in path.iterdir():
102+
if not child.is_dir():
103+
size += child.stat(follow_symlinks=False).st_size
104+
continue
105+
106+
# Skip symlinks to avoid infinite loops
107+
if child.is_symlink():
108+
continue
109+
110+
try:
111+
# Skip if not on same device (external mount)
112+
if child.stat().st_dev != root_device:
113+
continue
114+
except OSError as err:
115+
if err.errno == errno.EBADMSG:
116+
self.sys_resolution.add_unhealthy_reason(
117+
UnhealthyReason.OSERROR_BAD_MESSAGE
118+
)
119+
break
120+
continue
121+
122+
child_result = self.get_dir_structure_sizes(child, max_depth - 1)
123+
if child_result["used_bytes"] > 0:
124+
size += child_result["used_bytes"]
125+
if max_depth > 1:
126+
children.append(
127+
{
128+
"id": child.name,
129+
"label": child.name,
130+
**child_result,
131+
}
132+
)
133+
134+
if children:
135+
return {"used_bytes": size, "children": children}
136+
137+
return {"used_bytes": size}
138+
139+
def get_dir_sizes(
140+
self, request: dict[str, Path], max_depth: int = 1
141+
) -> list[dict[str, Any]]:
142+
"""Accept a dictionary of `name: Path` and return a dictionary with `name: <size>`.
143+
144+
Must be run in executor.
145+
"""
146+
return [
147+
{
148+
"id": name,
149+
"label": name,
150+
**self.get_dir_structure_sizes(path, max_depth),
151+
}
152+
for name, path in request.items()
153+
]
154+
76155
def _get_mountinfo(self, path: str) -> list[str] | None:
77156
mountinfo = _MOUNTINFO.read_text(encoding="utf-8")
78157
for line in mountinfo.splitlines():

0 commit comments

Comments
 (0)