linux-smoke-test.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. HOST="169.254.100.2"
  4. PORT="48888"
  5. PASSWORD="Dieteng2026"
  6. INTERFACE=""
  7. IP=""
  8. PREFIX=""
  9. GATEWAY=""
  10. DNS_CSV=""
  11. DO_APPLY=0
  12. DHCP4=0
  13. APPLY_DECISION="confirm"
  14. usage() {
  15. cat <<'EOF'
  16. Usage:
  17. linux-smoke-test.sh [options]
  18. Default behavior:
  19. Runs read-only smoke checks against the server:
  20. - GET /api/health
  21. - GET /api/device/info
  22. - GET /api/network/interfaces
  23. - GET /api/network/config?interface=...
  24. Validate only:
  25. linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1
  26. linux-smoke-test.sh --interface ens33 --dhcp4
  27. Apply and poll task:
  28. linux-smoke-test.sh --interface ens33 --ip 192.168.10.20 --prefix 24 --gateway 192.168.10.1 --dns 8.8.8.8,1.1.1.1 --apply
  29. linux-smoke-test.sh --interface ens33 --dhcp4 --apply
  30. Options:
  31. --host <ip> Server host, default: 169.254.100.2
  32. --port <port> Server port, default: 48888
  33. --password <value> Admin password, default: Dieteng2026
  34. --interface <name> Target Linux interface, e.g. ens33
  35. --ip <ipv4> IPv4 address for validate/apply
  36. --prefix <cidr> Prefix length for validate/apply
  37. --gateway <ipv4> Optional gateway
  38. --dns <csv> Optional DNS CSV, e.g. 8.8.8.8,1.1.1.1
  39. --dhcp4 Validate/apply DHCP IPv4 mode instead of static IPv4
  40. --apply Actually call /api/network/apply after validate
  41. --cancel-apply With --apply, cancel at confirmation step and expect rollback
  42. --help Show this help
  43. Notes:
  44. - Without --apply, the script never changes netplan.
  45. - With --apply, the server rewrites netplan YAML, runs netplan apply, then waits 20 seconds for confirmation.
  46. - By default the script confirms apply tasks automatically when they enter confirming.
  47. EOF
  48. }
  49. while [[ $# -gt 0 ]]; do
  50. case "$1" in
  51. --host)
  52. HOST="$2"
  53. shift 2
  54. ;;
  55. --port)
  56. PORT="$2"
  57. shift 2
  58. ;;
  59. --password)
  60. PASSWORD="$2"
  61. shift 2
  62. ;;
  63. --interface)
  64. INTERFACE="$2"
  65. shift 2
  66. ;;
  67. --ip)
  68. IP="$2"
  69. shift 2
  70. ;;
  71. --prefix)
  72. PREFIX="$2"
  73. shift 2
  74. ;;
  75. --gateway)
  76. GATEWAY="$2"
  77. shift 2
  78. ;;
  79. --dns)
  80. DNS_CSV="$2"
  81. shift 2
  82. ;;
  83. --dhcp4)
  84. DHCP4=1
  85. shift
  86. ;;
  87. --apply)
  88. DO_APPLY=1
  89. shift
  90. ;;
  91. --cancel-apply)
  92. APPLY_DECISION="cancel"
  93. shift
  94. ;;
  95. --help|-h)
  96. usage
  97. exit 0
  98. ;;
  99. *)
  100. echo "Unknown argument: $1" >&2
  101. usage >&2
  102. exit 2
  103. ;;
  104. esac
  105. done
  106. for cmd in curl python3; do
  107. if ! command -v "$cmd" >/dev/null 2>&1; then
  108. echo "Missing required command: $cmd" >&2
  109. exit 2
  110. fi
  111. done
  112. BASE_URL="http://${HOST}:${PORT}"
  113. TMP_DIR="$(mktemp -d)"
  114. trap 'rm -rf "$TMP_DIR"' EXIT
  115. log() {
  116. printf '[networktool-smoke] %s\n' "$*"
  117. }
  118. request() {
  119. local method="$1"
  120. local path="$2"
  121. local payload="${3:-}"
  122. local body_file="$TMP_DIR/body.json"
  123. local http_code
  124. : > "$body_file"
  125. if [[ -n "$payload" ]]; then
  126. http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
  127. -H "X-Admin-Password: ${PASSWORD}" \
  128. -H "Content-Type: application/json" \
  129. --data "$payload" \
  130. --connect-timeout 3 \
  131. --max-time 15 \
  132. "${BASE_URL}${path}" || true)"
  133. else
  134. http_code="$(curl -sS -o "$body_file" -w "%{http_code}" -X "$method" \
  135. -H "X-Admin-Password: ${PASSWORD}" \
  136. --connect-timeout 3 \
  137. --max-time 15 \
  138. "${BASE_URL}${path}" || true)"
  139. fi
  140. printf '%s\n' "$http_code"
  141. cat "$body_file"
  142. }
  143. read_http_code() {
  144. sed -n '1p'
  145. }
  146. read_body() {
  147. sed '1d'
  148. }
  149. assert_api_ok() {
  150. local op="$1"
  151. local http_code="$2"
  152. local body="$3"
  153. BODY_JSON="$body" python3 - "$op" "$http_code" <<'PY'
  154. import json
  155. import os
  156. import sys
  157. op = sys.argv[1]
  158. http_code = int(sys.argv[2])
  159. body = os.environ["BODY_JSON"]
  160. if not (200 <= http_code < 300):
  161. print(f"{op} failed: http={http_code} body={body}", file=sys.stderr)
  162. sys.exit(1)
  163. try:
  164. data = json.loads(body)
  165. except json.JSONDecodeError as exc:
  166. print(f"{op} failed: invalid json: {exc}: {body}", file=sys.stderr)
  167. sys.exit(1)
  168. if data.get("code") != 0:
  169. print(f"{op} failed: code={data.get('code')} message={data.get('message')} body={body}", file=sys.stderr)
  170. sys.exit(1)
  171. PY
  172. }
  173. json_get() {
  174. local body="$1"
  175. local expr="$2"
  176. BODY_JSON="$body" python3 - "$expr" <<'PY'
  177. import json
  178. import os
  179. import sys
  180. expr = sys.argv[1]
  181. data = json.loads(os.environ["BODY_JSON"])
  182. value = data
  183. for part in expr.split('.'):
  184. if part:
  185. value = value[part]
  186. if isinstance(value, list):
  187. import json as _json
  188. print(_json.dumps(value, ensure_ascii=False))
  189. else:
  190. print(value)
  191. PY
  192. }
  193. build_payload() {
  194. python3 - "$INTERFACE" "$IP" "$PREFIX" "$GATEWAY" "$DNS_CSV" "$DHCP4" <<'PY'
  195. import json
  196. import sys
  197. interface, ip, prefix, gateway, dns_csv, dhcp4 = sys.argv[1:7]
  198. is_dhcp4 = dhcp4 == "1"
  199. dns = [item.strip() for item in dns_csv.split(',') if item.strip()]
  200. payload = {
  201. "interface": interface,
  202. "dhcp4": is_dhcp4,
  203. "ip": "" if is_dhcp4 else ip,
  204. "prefix": 0 if is_dhcp4 else int(prefix),
  205. "gateway": "" if is_dhcp4 else gateway,
  206. "dns": [] if is_dhcp4 else dns,
  207. }
  208. print(json.dumps(payload, ensure_ascii=False))
  209. PY
  210. }
  211. build_task_payload() {
  212. python3 - "$1" <<'PY'
  213. import json
  214. import sys
  215. print(json.dumps({"task_id": sys.argv[1]}, ensure_ascii=False))
  216. PY
  217. }
  218. poll_task() {
  219. local task_id="$1"
  220. local max_attempts=20
  221. local attempt=1
  222. local connection_failures=0
  223. local confirmation_sent=0
  224. while (( attempt <= max_attempts )); do
  225. local result http_code body status step detail rollback
  226. result="$(request GET "/api/tasks/${task_id}")"
  227. http_code="$(printf '%s\n' "$result" | read_http_code)"
  228. body="$(printf '%s\n' "$result" | read_body)"
  229. if [[ "$http_code" == "000" || "$http_code" == "0" || -z "$http_code" ]]; then
  230. connection_failures=$((connection_failures + 1))
  231. log "task=${task_id} poll connection failed (${connection_failures}), retrying"
  232. sleep 1
  233. attempt=$((attempt + 1))
  234. continue
  235. fi
  236. connection_failures=0
  237. assert_api_ok "get task ${task_id}" "$http_code" "$body"
  238. status="$(json_get "$body" "data.status")"
  239. step="$(json_get "$body" "data.step")"
  240. detail="$(json_get "$body" "data.detail")"
  241. rollback="$(json_get "$body" "data.rollback")"
  242. log "task=${task_id} status=${status} step=${step} rollback=${rollback} detail=${detail}"
  243. if [[ "$status" == "running" && "$step" == "confirming" && "$confirmation_sent" -eq 0 ]]; then
  244. local decision_path decision_op decision_result decision_code decision_body
  245. if [[ "$APPLY_DECISION" == "cancel" ]]; then
  246. decision_path="/api/network/apply/cancel"
  247. decision_op="cancel apply ${task_id}"
  248. else
  249. decision_path="/api/network/apply/confirm"
  250. decision_op="confirm apply ${task_id}"
  251. fi
  252. log "task=${task_id} ${APPLY_DECISION} at confirmation step"
  253. decision_result="$(request POST "$decision_path" "$(build_task_payload "$task_id")")"
  254. decision_code="$(printf '%s\n' "$decision_result" | read_http_code)"
  255. decision_body="$(printf '%s\n' "$decision_result" | read_body)"
  256. assert_api_ok "$decision_op" "$decision_code" "$decision_body"
  257. confirmation_sent=1
  258. fi
  259. case "$status" in
  260. success)
  261. if [[ "$APPLY_DECISION" == "cancel" ]]; then
  262. echo "Expected rollback after cancel, but task succeeded: ${task_id}" >&2
  263. return 1
  264. fi
  265. return 0
  266. ;;
  267. rolled_back)
  268. if [[ "$APPLY_DECISION" == "cancel" ]]; then
  269. return 0
  270. fi
  271. return 1
  272. ;;
  273. failed)
  274. return 1
  275. ;;
  276. esac
  277. sleep 1
  278. attempt=$((attempt + 1))
  279. done
  280. echo "Timed out waiting for task: ${task_id}" >&2
  281. return 1
  282. }
  283. log "health"
  284. result="$(request GET "/api/health")"
  285. http_code="$(printf '%s\n' "$result" | read_http_code)"
  286. body="$(printf '%s\n' "$result" | read_body)"
  287. assert_api_ok "health" "$http_code" "$body"
  288. log "server_version=$(json_get "$body" "data.server_version")"
  289. log "device info"
  290. result="$(request GET "/api/device/info")"
  291. http_code="$(printf '%s\n' "$result" | read_http_code)"
  292. body="$(printf '%s\n' "$result" | read_body)"
  293. assert_api_ok "device info" "$http_code" "$body"
  294. log "device_id=$(json_get "$body" "data.device_id") hostname=$(json_get "$body" "data.hostname")"
  295. log "interfaces"
  296. result="$(request GET "/api/network/interfaces")"
  297. http_code="$(printf '%s\n' "$result" | read_http_code)"
  298. body="$(printf '%s\n' "$result" | read_body)"
  299. assert_api_ok "interfaces" "$http_code" "$body"
  300. if [[ -z "$INTERFACE" ]]; then
  301. INTERFACE="$(json_get "$body" "data.suggested_target_interface")"
  302. fi
  303. if [[ -z "$INTERFACE" || "$INTERFACE" == "None" ]]; then
  304. echo "Could not determine target interface. Pass --interface explicitly." >&2
  305. exit 1
  306. fi
  307. log "target interface=${INTERFACE} management=$(json_get "$body" "data.management_interface")"
  308. log "read current config"
  309. result="$(request GET "/api/network/config?interface=${INTERFACE}")"
  310. http_code="$(printf '%s\n' "$result" | read_http_code)"
  311. body="$(printf '%s\n' "$result" | read_body)"
  312. assert_api_ok "config ${INTERFACE}" "$http_code" "$body"
  313. log "current dhcp4=$(json_get "$body" "data.dhcp4") ip=$(json_get "$body" "data.ip") prefix=$(json_get "$body" "data.prefix") gateway=$(json_get "$body" "data.gateway")"
  314. if [[ -n "$IP" || -n "$PREFIX" || -n "$GATEWAY" || -n "$DNS_CSV" || "$DO_APPLY" -eq 1 || "$DHCP4" -eq 1 ]]; then
  315. if [[ "$DHCP4" -eq 0 && ( -z "$IP" || -z "$PREFIX" ) ]]; then
  316. echo "--ip and --prefix are required for static validate/apply; use --dhcp4 for DHCP mode" >&2
  317. exit 2
  318. fi
  319. payload="$(build_payload)"
  320. log "validate payload=${payload}"
  321. result="$(request POST "/api/network/validate" "$payload")"
  322. http_code="$(printf '%s\n' "$result" | read_http_code)"
  323. body="$(printf '%s\n' "$result" | read_body)"
  324. assert_api_ok "validate" "$http_code" "$body"
  325. log "validate passed"
  326. if (( DO_APPLY == 1 )); then
  327. log "apply"
  328. result="$(request POST "/api/network/apply" "$payload")"
  329. http_code="$(printf '%s\n' "$result" | read_http_code)"
  330. body="$(printf '%s\n' "$result" | read_body)"
  331. assert_api_ok "apply" "$http_code" "$body"
  332. task_id="$(json_get "$body" "data.task_id")"
  333. log "apply submitted task_id=${task_id}"
  334. poll_task "$task_id"
  335. if [[ "$APPLY_DECISION" == "cancel" ]]; then
  336. log "apply task rolled back after cancellation as expected"
  337. else
  338. log "apply task completed successfully"
  339. fi
  340. fi
  341. else
  342. log "read-only smoke checks completed"
  343. fi