Skip to content

Commit ce8f055

Browse files
committed
feat(api): cuos selftest
One call that answers "is this system healthy?", for a support case and for an automated test alike. It only reads, so it is safe to run at any time, and it reports {ok, summary, checks[]} rather than a number - because api_main returns 0 for every command and an exit code would say nothing. Checked: config, version, slot, subvolumes, state, docker, app, network, hostname, ssh, log. A check is skipped where it does not apply - a container has no subvolumes, and its interface belongs to its host - never to hide a problem. Every function that looks outward is a seam the test replaces, so selftest.test.sh covers all of it with no system underneath. Signed-off-by: Simon Walz <contact@simonwalz.de>
1 parent b69def8 commit ce8f055

4 files changed

Lines changed: 534 additions & 0 deletions

File tree

‎docs/common/cuos-api.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ cuos state
2828

2929
# Get system resources
3030
cuos resources
31+
32+
# Check the invariants of this system
33+
cuos selftest
3134
```
3235

3336
### System Management
@@ -85,6 +88,32 @@ Example output:
8588
}
8689
```
8790

91+
### Self-test
92+
93+
`cuos selftest` reads the system and reports whether its invariants hold —
94+
useful in a support case, and as the assertion an automated test makes. It
95+
changes nothing.
96+
97+
```json
98+
{
99+
"ok": false,
100+
"summary": { "ok": 9, "failed": 1, "skipped": 1 },
101+
"checks": [
102+
{ "id": "slot", "status": "ok", "detail": "A" },
103+
{ "id": "subvolumes", "status": "skipped", "detail": "a container has no subvolumes; the root is swapped instead" },
104+
{ "id": "app", "status": "failed", "detail": "cuos-app is exited" }
105+
]
106+
}
107+
```
108+
109+
Checked: `config`, `version`, `slot`, `subvolumes`, `state`, `docker`, `app`,
110+
`network`, `hostname`, `ssh`, `log`. A check is **skipped** when it does not
111+
apply to this kind of system — a container has no subvolumes, and its interface
112+
is configured by its host — never to hide a problem.
113+
114+
**Read `ok` from the output, not the exit code**: the API exits 0 for every
115+
command, because systemd socket activation stops working otherwise.
116+
88117
## Using the API in Your Application
89118

90119
### Error Handling
@@ -95,6 +124,9 @@ The API returns standard exit codes:
95124
- 2: No new version available
96125
- 3: No space left for update
97126

127+
**Except when invoked as `cuos <command>`**, where the exit code is always 0 —
128+
see the note under Self-test. Read the result from the output.
129+
98130

99131
## API Commands Reference
100132

@@ -106,6 +138,7 @@ The API returns standard exit codes:
106138
| update | Apply system update | `{ "config": new system config }` | Status messages | Initiates system update |
107139
| rollback | Rollback last update | `{}` | Status messages | Reverts to previous version |
108140
| resources | Show system resources | `{}` | `{ "cpu_cores": number, "cpu_usage": number, ... }` | System metrics |
141+
| selftest | Check this system's invariants | `{}` | `{ "ok": bool, "summary": {...}, "checks": [...] }` | Read-only; see [Self-test](#self-test) |
109142
| patch-network | Set network configuration | `{"network_id": 0,"config": {"dhcp": true}}` | Status message | See [Documentation system.json](./system-json.md) |
110143
| patch-hostname | Set system hostname | `"myhostname"` | Status message | Set new hostname |
111144

‎system/cuos/api.sh‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,13 @@ api_command_resources() {
349349
"${SCRIPT_DIR}/resources.sh"
350350
}
351351

352+
## selftest - Check the invariants of this system
353+
## Reports {"ok": bool, "checks": [...]}. Note that the
354+
## exit code is always 0 - read "ok" from the output.
355+
api_command_selftest() {
356+
"${SCRIPT_DIR}/selftest.sh"
357+
}
358+
352359
api_main() {
353360
local input="{}"
354361
local command="${1:-""}"

‎system/cuos/selftest.sh‎

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
#!/bin/bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# selftest.sh — check the invariants of a running CuOS system and report them
5+
# as JSON. Reached as `cuos selftest`.
6+
#
7+
# This answers "is this system actually healthy?" in one call, for a support
8+
# case and for an automated test alike. It only reads: nothing here changes the
9+
# system, so it is safe to run at any time.
10+
#
11+
# Every check reports ok, failed or skipped. A check is skipped when it does not
12+
# apply to this kind of system - a container has no disk subvolumes, and its
13+
# network belongs to the host - never to hide a problem.
14+
15+
set -uo pipefail
16+
17+
CONFIG_PATH="${CONFIG_PATH:-"/system.json"}"
18+
FILE_STATE="${T_FILE_STATE:-"/data/state.json"}"
19+
FILE_SLOT="${T_FILE_SLOT:-"/etc/active_slot"}"
20+
FILE_VERSION="${T_FILE_VERSION:-"/etc/image"}"
21+
APP_CONTAINER="${APP_CONTAINER:-"cuos-app"}"
22+
SSH_PORT="${SSH_PORT:-"4222"}"
23+
24+
RESULTS=()
25+
26+
record() {
27+
RESULTS+=("$(jq -nc \
28+
--arg id "$1" --arg status "$2" --arg detail "${3:-}" \
29+
'{id: $id, status: $status, detail: $detail}')")
30+
}
31+
ok() { record "$1" "ok" "${2:-}"; }
32+
failed() { record "$1" "failed" "$2"; }
33+
skipped() { record "$1" "skipped" "$2"; }
34+
35+
cfg() {
36+
jq -r "$1" "${CONFIG_PATH}" 2>/dev/null
37+
}
38+
39+
# ------------------------------------------------------------ the outside world
40+
#
41+
# Everything the checks learn about the machine goes through one of these, so a
42+
# test can replace them without a running system underneath.
43+
44+
virt_type() {
45+
systemd-detect-virt 2>/dev/null || echo "none"
46+
}
47+
48+
is_container() {
49+
local virt
50+
virt="$(virt_type)"
51+
[[ "${virt}" == "lxc" || "${virt}" == "docker" ]]
52+
}
53+
54+
# "<subvolume> <mountpoint>" per line, for every mounted btrfs subvolume.
55+
subvolumes() {
56+
findmnt -t btrfs -no TARGET,OPTIONS 2>/dev/null \
57+
| sed -n 's|^\(\S*\)\s.*subvol=/\([^,]*\).*|\2 \1|p'
58+
}
59+
60+
# "<interface> <address>/<prefix>" per line.
61+
host_addresses() {
62+
ip -o -4 addr show 2>/dev/null | awk '{print $2, $4}'
63+
}
64+
65+
system_hostname() {
66+
hostname 2>/dev/null
67+
}
68+
69+
docker_ok() {
70+
docker info >/dev/null 2>&1
71+
}
72+
73+
container_state() {
74+
docker inspect --format '{{.State.Status}}' "$1" 2>/dev/null
75+
}
76+
77+
listening_on() {
78+
ss -ltn 2>/dev/null | grep -qE "[:.]$1[[:space:]]"
79+
}
80+
81+
# How many cuos journal entries of priority error or worse this boot produced.
82+
journal_errors() {
83+
journalctl -t cuos -b -p 3 --no-pager -o cat 2>/dev/null | grep -c . || true
84+
}
85+
86+
# ------------------------------------------------------------------- the checks
87+
88+
check_config() {
89+
if [[ ! -f "${CONFIG_PATH}" ]]; then
90+
failed "config" "${CONFIG_PATH} does not exist"
91+
return
92+
fi
93+
if ! jq -e . "${CONFIG_PATH}" >/dev/null 2>&1; then
94+
failed "config" "${CONFIG_PATH} is not valid JSON"
95+
return
96+
fi
97+
ok "config" "$(cfg '."__filename" // "system.json"')"
98+
}
99+
100+
check_version() {
101+
local version
102+
version="$(cat "${FILE_VERSION}" 2>/dev/null)"
103+
if [[ -z "${version}" ]]; then
104+
failed "version" "${FILE_VERSION} is empty or missing"
105+
return
106+
fi
107+
ok "version" "${version}"
108+
}
109+
110+
check_slot() {
111+
local slot
112+
slot="$(cat "${FILE_SLOT}" 2>/dev/null)"
113+
case "${slot}" in
114+
A|B) ok "slot" "${slot}" ;;
115+
"") failed "slot" "${FILE_SLOT} is empty or missing" ;;
116+
*) failed "slot" "${FILE_SLOT} holds '${slot}', expected A or B" ;;
117+
esac
118+
}
119+
120+
# The A/B design rests on these being separate subvolumes. A system that booted
121+
# with @data missing would look healthy and lose every bit of state on update.
122+
check_subvolumes() {
123+
if is_container; then
124+
skipped "subvolumes" "a container has no subvolumes; the root is swapped instead"
125+
return
126+
fi
127+
128+
local mounted missing=""
129+
mounted="$(subvolumes)"
130+
local wanted
131+
for wanted in "@os" "@data"; do
132+
if ! grep -q "^${wanted}[[:space:]]" <<<"${mounted}"; then
133+
missing+="${missing:+, }${wanted}"
134+
fi
135+
done
136+
137+
if [[ -n "${missing}" ]]; then
138+
failed "subvolumes" "not mounted: ${missing}"
139+
return
140+
fi
141+
ok "subvolumes" "$(tr '\n' ' ' <<<"$(cut -d' ' -f1 <<<"${mounted}")" | sed 's/ $//')"
142+
}
143+
144+
check_state() {
145+
if [[ ! -f "${FILE_STATE}" ]]; then
146+
failed "state" "${FILE_STATE} does not exist"
147+
return
148+
fi
149+
150+
local state starting
151+
state="$(jq -r '.state // "unknown"' "${FILE_STATE}" 2>/dev/null)"
152+
starting="$(jq -r '.starting // false' "${FILE_STATE}" 2>/dev/null)"
153+
154+
if [[ "${state}" != "running" ]]; then
155+
failed "state" "state is '${state}', expected 'running'"
156+
return
157+
fi
158+
# The application clears this through 'report_app_ready'. An application that
159+
# never reports leaves it true forever, which is why this is a warning in the
160+
# detail rather than a failure.
161+
if [[ "${starting}" == "true" ]]; then
162+
ok "state" "running, but the application has not reported itself ready"
163+
return
164+
fi
165+
ok "state" "running"
166+
}
167+
168+
check_docker() {
169+
if docker_ok; then
170+
ok "docker"
171+
return
172+
fi
173+
failed "docker" "docker is not responding"
174+
}
175+
176+
check_app() {
177+
local status
178+
status="$(container_state "${APP_CONTAINER}")"
179+
case "${status}" in
180+
running) ok "app" "${APP_CONTAINER} is running" ;;
181+
"") failed "app" "there is no ${APP_CONTAINER} container" ;;
182+
*) failed "app" "${APP_CONTAINER} is ${status}" ;;
183+
esac
184+
}
185+
186+
# Every statically configured address has to be on an interface. This is what
187+
# catches a configuration that was written but never applied.
188+
check_network() {
189+
if is_container; then
190+
skipped "network" "a container's interface is configured by its host"
191+
return
192+
fi
193+
194+
local count
195+
count="$(cfg '(.network // []) | length')"
196+
if [[ "${count}" == "0" ]]; then
197+
skipped "network" "no network section in the configuration"
198+
return
199+
fi
200+
201+
local addresses missing="" found=""
202+
addresses="$(host_addresses)"
203+
204+
local i address
205+
for (( i = 0; i < count; i++ )); do
206+
address="$(cfg ".network[${i}][\"ip-address\"] // empty")"
207+
if [[ -z "${address}" ]]; then
208+
# DHCP, or an entry that only identifies an interface.
209+
continue
210+
fi
211+
if grep -qE "[[:space:]]${address}/" <<<"${addresses}"; then
212+
found+="${found:+, }${address}"
213+
else
214+
missing+="${missing:+, }${address}"
215+
fi
216+
done
217+
218+
if [[ -n "${missing}" ]]; then
219+
failed "network" "configured but not on any interface: ${missing}"
220+
return
221+
fi
222+
if [[ -z "${found}" ]]; then
223+
skipped "network" "every interface is on DHCP"
224+
return
225+
fi
226+
ok "network" "${found}"
227+
}
228+
229+
check_hostname() {
230+
local wanted actual
231+
wanted="$(cfg '.hostname // empty')"
232+
if [[ -z "${wanted}" ]]; then
233+
skipped "hostname" "no hostname in the configuration"
234+
return
235+
fi
236+
actual="$(system_hostname)"
237+
if [[ "${actual}" != "${wanted}" ]]; then
238+
failed "hostname" "system says '${actual}', configuration says '${wanted}'"
239+
return
240+
fi
241+
ok "hostname" "${actual}"
242+
}
243+
244+
check_ssh() {
245+
local wanted
246+
wanted="$(cfg '.os_ssh_server // false')"
247+
if [[ "${wanted}" != "true" ]]; then
248+
skipped "ssh" "not enabled in the configuration"
249+
return
250+
fi
251+
if listening_on "${SSH_PORT}"; then
252+
ok "ssh" "listening on ${SSH_PORT}"
253+
return
254+
fi
255+
failed "ssh" "enabled in the configuration, but nothing listens on ${SSH_PORT}"
256+
}
257+
258+
check_log() {
259+
local errors
260+
errors="$(journal_errors)"
261+
errors="${errors//[^0-9]/}"
262+
if [[ -z "${errors}" || "${errors}" == "0" ]]; then
263+
ok "log" "no errors this boot"
264+
return
265+
fi
266+
failed "log" "${errors} cuos log entries of level error or worse this boot"
267+
}
268+
269+
## selftest - Check the invariants of this system
270+
selftest() {
271+
check_config
272+
check_version
273+
check_slot
274+
check_subvolumes
275+
check_state
276+
check_docker
277+
check_app
278+
check_network
279+
check_hostname
280+
check_ssh
281+
check_log
282+
283+
printf '%s\n' "${RESULTS[@]}" | jq -s '
284+
{
285+
ok: ((map(select(.status == "failed")) | length) == 0),
286+
summary: {
287+
ok: (map(select(.status == "ok")) | length),
288+
failed: (map(select(.status == "failed")) | length),
289+
skipped: (map(select(.status == "skipped")) | length)
290+
},
291+
checks: .
292+
}'
293+
}
294+
295+
# Execute only if the script is run, not sourced.
296+
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
297+
selftest
298+
fi

0 commit comments

Comments
 (0)