gpu-usage-audit 1.1.0__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/CHANGELOG.md +11 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/PKG-INFO +1 -1
- gpu_usage_audit-1.3.0/docs/work-specs/0002-daemon-cloud-mode.ko.md +43 -0
- gpu_usage_audit-1.3.0/docs/work-specs/0003-collection-status-emit.ko.md +50 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/pyproject.toml +1 -1
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/__main__.py +122 -7
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/snapshot.py +29 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/daemon.py +18 -3
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/nvml.py +16 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_cli.py +196 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_snapshot.py +31 -1
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_daemon.py +49 -1
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_nvml.py +35 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/uv.lock +1 -1
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.github/workflows/ci.yml +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.github/workflows/release.yml +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/.gitignore +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/LICENSE +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/README.ko.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/README.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/docs/work-specs/0001-gua-board-cloud-sync.ko.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/handoff.ko.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/plan.ko.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/projects/bare-metal-1.0/status.ko.md +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/scripts/check-tag-version.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/scripts/smoke-dist-wheel.sh +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/__init__.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/classify.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/__init__.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/client.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/cloud/config.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/db.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/doctor.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/identity.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/model.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/paths.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/render.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/report.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/summarize.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/src/gpu_usage_audit/tier.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/__init__.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_classify.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_client.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_cloud_config.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_db.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_doctor.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_identity.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_render.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_report.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_smoke.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_summarize.py +0 -0
- {gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/tests/test_tier.py +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0 - 2026-06-18
|
|
4
|
+
|
|
5
|
+
- Cloud sync now emits a real `collectionStatus` instead of always reporting
|
|
6
|
+
`ok`. When core GPU metrics are collected but one or more cards' per-process
|
|
7
|
+
list is unavailable (permissions or transient NVML errors), `gua sync-once`
|
|
8
|
+
and `gua daemon --cloud` push `partial` with a `process_list_unavailable`
|
|
9
|
+
error while still sending the GPU data. When NVML initialization fails
|
|
10
|
+
entirely, `gua sync-once` now pushes an `error` heartbeat
|
|
11
|
+
(`nvml_init_failed`, empty GPU inventory) so a host that lost its driver
|
|
12
|
+
still surfaces a non-ok freshness signal on the board, then exits non-zero.
|
|
13
|
+
|
|
3
14
|
## 1.1.0 - 2026-06-17
|
|
4
15
|
|
|
5
16
|
- Added optional GUA Board cloud sync. `gua enroll` claims a one-time
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gpu-usage-audit
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Single-host daemon that surfaces 'idle-held' NVIDIA GPU memory — the embarrassing category conventional dashboards miss.
|
|
5
5
|
Project-URL: Homepage, https://github.com/AI-Ocean/gpu-usage-audit
|
|
6
6
|
Project-URL: Issues, https://github.com/AI-Ocean/gpu-usage-audit/issues
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# 0002 Daemon Cloud Mode (continuous push)
|
|
2
|
+
|
|
3
|
+
상태: draft
|
|
4
|
+
관련: 0001 (enroll + sync-once)
|
|
5
|
+
목표: `gua daemon --cloud` — 데몬이 매 틱 local DB 에 기록한 뒤 latest snapshot 을 GUA Board 로 push 한다. 일회성 `sync-once` 를 연속 운영으로 확장(없으면 보드가 stale).
|
|
6
|
+
|
|
7
|
+
## 배경
|
|
8
|
+
|
|
9
|
+
0001 에서 enroll + sync-once(1회 수집→local write→push)가 생겼다. 보드가 살아있으려면 호스트가 주기적으로 latest 를 올려야 한다. 기존 `gua daemon` 루프(anti-drift, 시그널 종료, local-write-first)를 재사용해 cloud push 를 얹는다.
|
|
10
|
+
|
|
11
|
+
## 범위
|
|
12
|
+
|
|
13
|
+
포함:
|
|
14
|
+
|
|
15
|
+
- `daemon` 에 `--cloud` + `--config` 플래그(두 CLI 파서 모두: `gua`, `gpu-usage-audit`).
|
|
16
|
+
- `daemon.run_daemon`/`_tick` 에 optional `on_tick(snap, ts)` 후크. **daemon 모듈은 cloud 를 import 하지 않는다** — CLI 가 콜백 주입(결합도 분리). 후크는 local write *이후* 호출, 실패해도 로그만 남기고 다음 틱 계속(local-write-first 불변식).
|
|
17
|
+
- `_cmd_daemon`: `--cloud` 면 NVML 열기 *전에* `load_cloud_config` 검증(미enroll → exit 2). push 콜백 = `build_observation_payload` + `post_observation`(0001 재사용).
|
|
18
|
+
- `gua daemon --cloud`(백그라운드)면 spawn 커맨드에 `--cloud --config` 전파.
|
|
19
|
+
|
|
20
|
+
제외:
|
|
21
|
+
|
|
22
|
+
- pull/명령 채널, 재시도 백오프 정교화, 오프라인 큐잉(실패 틱은 다음 틱이 latest 로 덮음 — replay 안 함).
|
|
23
|
+
- systemd 유닛 패키징(설치 UX 별도).
|
|
24
|
+
|
|
25
|
+
## Acceptance
|
|
26
|
+
|
|
27
|
+
- `gua daemon --cloud`(enrolled): 매 틱 local 기록 + latest push, 보드에 호스트/GPU 표시.
|
|
28
|
+
- push 실패(네트워크/CloudError/payload ValueError)는 데몬을 멈추지 않고 local 기록도 보존.
|
|
29
|
+
- `--cloud` + 미enroll → exit 2(NVML 열기 전).
|
|
30
|
+
- `--cloud` 없으면 기존 동작 그대로(push 없음).
|
|
31
|
+
- 백그라운드 `gua daemon --cloud` 가 자식 프로세스로 옵션 전파.
|
|
32
|
+
|
|
33
|
+
## Verification
|
|
34
|
+
|
|
35
|
+
- `tests/test_daemon.py`: `on_tick` 매 틱 local write 이후 호출 + raise 해도 데몬 계속·local 보존.
|
|
36
|
+
- `tests/test_cloud_cli.py`: `--cloud` 미enroll → exit 2(`run \`gua enroll\``); 백그라운드 spawn 커맨드에 `--cloud/--config` 포함.
|
|
37
|
+
- 전체 `pytest` 163 passed, `ruff` clean.
|
|
38
|
+
|
|
39
|
+
## Implementation Notes
|
|
40
|
+
|
|
41
|
+
- on_tick 후크 타입 `OnTick = Callable[[Snapshot, datetime], None]` (daemon.py).
|
|
42
|
+
- 실패 처리: `_tick` 이 on_tick 을 try/except 로 감싸 `logger.exception` 후 계속(틱 자체는 성공으로 간주).
|
|
43
|
+
- "push latest only, no replay" — 실패 틱을 재전송하지 않고 다음 틱 latest 가 보드를 갱신.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# 0003 Collection Status Emit (partial / error)
|
|
2
|
+
|
|
3
|
+
상태: implemented
|
|
4
|
+
관련: 0001 (enroll + sync-once, payload builder), 0002 (daemon cloud mode)
|
|
5
|
+
목표: agent 가 수집이 실제로 저하/실패했을 때 `collectionStatus` 를 `partial`/`error` 로 emit 한다. 지금까지 두 push call site 는 항상 `ok` 만 보냈다. board 측은 partial/error 를 이미 검증·저장하므로 board 변경은 없다 (agent 전용).
|
|
6
|
+
|
|
7
|
+
## 배경
|
|
8
|
+
|
|
9
|
+
0001 의 payload builder(`build_observation_payload`)는 `ok`/`partial`/`error` 셋 다 지원·검증한다 (`partial`/`error` 는 errors ≥1, `ok` 는 errors 비어야 함). 하지만 MVP 는 후속으로 미뤄 두 call site(`sync-once`, daemon `push_snapshot`)가 status/errors 를 생략해 항상 `ok` 였다 (0001 Implementation Notes "collectionStatus 매핑").
|
|
10
|
+
|
|
11
|
+
실제 운영에서 수집은 두 가지로 저하된다:
|
|
12
|
+
- 일부 카드의 process list 가 권한/일시오류로 안 읽힌다 — core GPU metric 은 정상. board 가 idle-held 신호를 *과소평가* 할 수 있으니 `partial` 로 알려야 한다.
|
|
13
|
+
- NVML init 자체가 실패한다(드라이버 손실 등) — GPU inventory 가 아예 없다. crash 로 끝내는 대신 `error` heartbeat 를 보내 board 가 non-ok freshness 로 표시하게 한다.
|
|
14
|
+
|
|
15
|
+
## 범위
|
|
16
|
+
|
|
17
|
+
포함:
|
|
18
|
+
|
|
19
|
+
- `NVMLTier.collect()` 가 그 틱에 process list 를 못 읽은 카드를 *틱마다 리셋* 되는 집합으로 추적하고 `last_process_list_unavailable: bool` 로 노출. 기존 누적 warning 집합(반복 로그 억제)과 분리.
|
|
20
|
+
- `cloud.snapshot.derive_collection_status(snapshot, *, process_list_unavailable)` — 수집 결과 → `(status, errors)`. GPU 가 있고 process list 가 비었으면 `partial` + `["process_list_unavailable"]`, 그 외 `ok`. contract 와 같은 곳에 둬 두 call site 가 같은 규칙 공유.
|
|
21
|
+
- 안정적 short error code 상수: `process_list_unavailable`, `nvml_init_failed`.
|
|
22
|
+
- `sync-once`: collect 후 partial 신호를 builder 로 thread. NVML init 실패 시 `error` heartbeat(빈 inventory, `nvml_init_failed`) push 후 non-zero exit. 데이터가 없으므로 local DB 는 쓰지 않는다.
|
|
23
|
+
- daemon `push_snapshot`: 매 틱 `tier.last_process_list_unavailable` 로 partial 도출해 thread (on_tick 은 같은 스레드에서 collect 직후 동기 호출이라 일치).
|
|
24
|
+
|
|
25
|
+
제외:
|
|
26
|
+
|
|
27
|
+
- daemon 의 `error` heartbeat. daemon 은 NVML 열기 *전에* probe 실패면 즉시 종료하는 구조라(0002 `_cmd_daemon`), error heartbeat 를 끼우려면 cloud config 검증 순서·종료 경로를 재배치해야 한다. 위험 대비 가치가 낮아 보류 — sync-once 의 error heartbeat 로 단발 진단은 가능하다.
|
|
28
|
+
- 새 error code 분류(예: GPU별 temperature/power 실패) — builder 는 음수 sentinel 을 0 으로 눌러 이미 흡수하므로 partial 로 격상하지 않는다.
|
|
29
|
+
- 신규 의존성/version bump/release tag (별도 수동 단계).
|
|
30
|
+
|
|
31
|
+
## Acceptance
|
|
32
|
+
|
|
33
|
+
- 모든 카드 정상 수집 → `ok`, errors 빈 배열 (기존과 동일).
|
|
34
|
+
- 한 카드라도 process list 가 NVMLError 로 비고 core GPU metric 은 수집됨 → `partial` + `["process_list_unavailable"]`, GPU 데이터는 그대로 push.
|
|
35
|
+
- partial 은 *틱 단위* — 다음 틱에 process list 가 복구되면 `ok` 로 돌아온다.
|
|
36
|
+
- NVML init 실패(`sync-once`) → `error` + `["nvml_init_failed"]` heartbeat push, GPU 빈 배열, local write 없음, non-zero exit. heartbeat push 도 실패하면 두 에러를 모두 보고.
|
|
37
|
+
- 두 call site 가 도출한 status/errors 를 builder 에 넘기며, builder 검증을 그대로 통과(partial/error errors ≥1).
|
|
38
|
+
|
|
39
|
+
## Verification
|
|
40
|
+
|
|
41
|
+
- `tests/test_nvml.py`: `last_process_list_unavailable` 가 process list 실패 틱에 True, 복구 틱에 False 로 리셋(틱 단위), core GPU metric 유지.
|
|
42
|
+
- `tests/test_cloud_snapshot.py`: `derive_collection_status` ok/partial 도출, GPU 0개면 partial flag 무시(ok), partial 도출값이 builder 검증 통과.
|
|
43
|
+
- `tests/test_cloud_cli.py`: `sync-once` 가 process list 불가 시 `partial` payload + GPU 보존 + local write; NVML init 실패 시 `error` heartbeat(빈 inventory, local write 없음, exit 1); heartbeat push 실패 시 두 에러 모두 보고.
|
|
44
|
+
- 전체 `uv run pytest`: 170 passed (기존 163 + 신규 7). `ruff check` clean, `mypy` clean.
|
|
45
|
+
|
|
46
|
+
## Implementation Notes
|
|
47
|
+
|
|
48
|
+
- partial 판정은 `derive_collection_status` 한곳 — `sync-once`(`NVMLTier.last_process_list_unavailable`) 와 daemon(같은 tier 인스턴스 closure) 이 공유.
|
|
49
|
+
- error heartbeat 는 `Snapshot()`(빈) 으로 builder 호출 — GPU 0개라 검증 통과하고, errors 비우지 않아 `error` contract 충족. driver_version 은 probe 실패라 `"unknown"`.
|
|
50
|
+
- daemon error heartbeat 는 보류 — 위 "제외" 참조. partial 경로는 daemon·sync-once 양쪽 모두 적용.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "gpu-usage-audit"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "Single-host daemon that surfaces 'idle-held' NVIDIA GPU memory — the embarrassing category conventional dashboards miss."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -34,16 +34,21 @@ from pathlib import Path
|
|
|
34
34
|
from . import __version__
|
|
35
35
|
from .cloud.client import CloudError, claim_enrollment, post_observation
|
|
36
36
|
from .cloud.config import (
|
|
37
|
+
CloudConfig,
|
|
37
38
|
CloudConfigError,
|
|
38
39
|
load_cloud_config,
|
|
39
40
|
save_cloud_config,
|
|
40
41
|
)
|
|
41
|
-
from .cloud.snapshot import
|
|
42
|
+
from .cloud.snapshot import (
|
|
43
|
+
ERROR_NVML_INIT_FAILED,
|
|
44
|
+
build_observation_payload,
|
|
45
|
+
derive_collection_status,
|
|
46
|
+
)
|
|
42
47
|
from .daemon import install_signal_handlers, resolve_proc_identities, run_daemon
|
|
43
48
|
from .db import open_db, write_snapshot
|
|
44
49
|
from .doctor import build_doctor_report, doctor_report_to_dict, render_doctor
|
|
45
50
|
from .identity import system_process_name_lookup, system_user_lookup
|
|
46
|
-
from .model import HostMeta
|
|
51
|
+
from .model import HostMeta, Snapshot
|
|
47
52
|
from .nvml import NVMLNotAvailableError, NVMLTier
|
|
48
53
|
from .paths import (
|
|
49
54
|
DEFAULT_CLOUD_CONFIG_PATH,
|
|
@@ -121,6 +126,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
121
126
|
default=timedelta(seconds=30),
|
|
122
127
|
help="Tick interval (e.g. 30s, 1m, 200ms) [default: 30s]",
|
|
123
128
|
)
|
|
129
|
+
_add_cloud_args(p_daemon)
|
|
124
130
|
p_daemon.set_defaults(func=_cmd_daemon)
|
|
125
131
|
|
|
126
132
|
p_report = sub.add_parser(
|
|
@@ -198,6 +204,19 @@ def _add_daemon_args(parser: argparse.ArgumentParser) -> None:
|
|
|
198
204
|
)
|
|
199
205
|
|
|
200
206
|
|
|
207
|
+
def _add_cloud_args(parser: argparse.ArgumentParser) -> None:
|
|
208
|
+
parser.add_argument(
|
|
209
|
+
"--cloud",
|
|
210
|
+
action="store_true",
|
|
211
|
+
help="After each tick, push the latest snapshot to GUA Board (requires `gua enroll`)",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"--config",
|
|
215
|
+
default=str(DEFAULT_CLOUD_CONFIG_PATH),
|
|
216
|
+
help=f"Cloud config path (used with --cloud) [default: {DEFAULT_CLOUD_CONFIG_PATH}]",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
201
220
|
def _add_report_args(parser: argparse.ArgumentParser) -> None:
|
|
202
221
|
parser.add_argument(
|
|
203
222
|
"--db",
|
|
@@ -294,6 +313,7 @@ def build_gua_parser() -> argparse.ArgumentParser:
|
|
|
294
313
|
)
|
|
295
314
|
_add_daemon_args(p_daemon)
|
|
296
315
|
_add_runtime_file_args(p_daemon)
|
|
316
|
+
_add_cloud_args(p_daemon)
|
|
297
317
|
p_daemon.add_argument(
|
|
298
318
|
"--foreground",
|
|
299
319
|
action="store_true",
|
|
@@ -307,6 +327,7 @@ def build_gua_parser() -> argparse.ArgumentParser:
|
|
|
307
327
|
)
|
|
308
328
|
_add_daemon_args(p_start)
|
|
309
329
|
_add_runtime_file_args(p_start)
|
|
330
|
+
_add_cloud_args(p_start)
|
|
310
331
|
p_start.set_defaults(func=_cmd_gua_start)
|
|
311
332
|
|
|
312
333
|
p_status = sub.add_parser(
|
|
@@ -483,6 +504,8 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
|
|
|
483
504
|
return 2
|
|
484
505
|
|
|
485
506
|
observed_at = datetime.now(UTC)
|
|
507
|
+
hostname = socket.gethostname() or "unknown"
|
|
508
|
+
process_list_unavailable = False
|
|
486
509
|
if args.fake:
|
|
487
510
|
tier = FakeTier()
|
|
488
511
|
driver = tier.probe()
|
|
@@ -493,14 +516,19 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
|
|
|
493
516
|
try:
|
|
494
517
|
driver = nvml_tier.probe()
|
|
495
518
|
except NVMLNotAvailableError as exc:
|
|
496
|
-
|
|
497
|
-
|
|
519
|
+
# 드라이버를 잃은 host 도 board 가 non-ok freshness 로 보여줄 수
|
|
520
|
+
# 있게 error heartbeat 를 push 한다 (GPU inventory 없음, local
|
|
521
|
+
# write 도 없음 — 기록할 데이터가 없다).
|
|
522
|
+
return _push_error_heartbeat(config, hostname, observed_at, exc)
|
|
498
523
|
snap = nvml_tier.collect(observed_at)
|
|
524
|
+
process_list_unavailable = nvml_tier.last_process_list_unavailable
|
|
499
525
|
resolve_proc_identities(snap.procs, system_user_lookup, system_process_name_lookup)
|
|
500
526
|
finally:
|
|
501
527
|
nvml_tier.close()
|
|
502
528
|
|
|
503
|
-
|
|
529
|
+
collection_status, errors = derive_collection_status(
|
|
530
|
+
snap, process_list_unavailable=process_list_unavailable
|
|
531
|
+
)
|
|
504
532
|
host = HostMeta(
|
|
505
533
|
hostname=hostname,
|
|
506
534
|
env_kind=LOCAL_ENV_KIND,
|
|
@@ -528,6 +556,8 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
|
|
|
528
556
|
observed_at=observed_at,
|
|
529
557
|
host_id=config.host_id,
|
|
530
558
|
display_name=config.display_name,
|
|
559
|
+
collection_status=collection_status,
|
|
560
|
+
errors=errors,
|
|
531
561
|
)
|
|
532
562
|
except ValueError as exc:
|
|
533
563
|
print(
|
|
@@ -546,14 +576,53 @@ def _cmd_gua_sync_once(args: argparse.Namespace) -> int:
|
|
|
546
576
|
)
|
|
547
577
|
return 1
|
|
548
578
|
|
|
579
|
+
status_note = "" if collection_status == "ok" else f" [{collection_status}: {','.join(errors)}]"
|
|
549
580
|
print(
|
|
550
581
|
f"gua sync-once: pushed {len(payload['gpus'])} GPUs to "
|
|
551
|
-
f"{config.display_name} ({config.server_url})"
|
|
582
|
+
f"{config.display_name} ({config.server_url}){status_note}"
|
|
552
583
|
)
|
|
553
584
|
print(f" local snapshot saved to {db_path}")
|
|
554
585
|
return 0
|
|
555
586
|
|
|
556
587
|
|
|
588
|
+
def _push_error_heartbeat(
|
|
589
|
+
config: CloudConfig,
|
|
590
|
+
hostname: str,
|
|
591
|
+
observed_at: datetime,
|
|
592
|
+
exc: NVMLNotAvailableError,
|
|
593
|
+
) -> int:
|
|
594
|
+
"""NVML init 실패 시 board 에 `error` heartbeat 만 push 한다 (데이터 없음).
|
|
595
|
+
|
|
596
|
+
GPU inventory 가 비어 있어 local DB 에 쓸 게 없으므로 push 만 한다. push
|
|
597
|
+
실패는 비치명적 — 에러를 보고하고 non-zero exit 로 신호한다.
|
|
598
|
+
"""
|
|
599
|
+
payload = build_observation_payload(
|
|
600
|
+
snapshot=Snapshot(),
|
|
601
|
+
hostname=hostname,
|
|
602
|
+
driver_version="unknown",
|
|
603
|
+
agent_version=__version__,
|
|
604
|
+
observed_at=observed_at,
|
|
605
|
+
host_id=config.host_id,
|
|
606
|
+
display_name=config.display_name,
|
|
607
|
+
collection_status="error",
|
|
608
|
+
errors=[ERROR_NVML_INIT_FAILED],
|
|
609
|
+
)
|
|
610
|
+
try:
|
|
611
|
+
post_observation(config, payload)
|
|
612
|
+
except CloudError as push_exc:
|
|
613
|
+
print(
|
|
614
|
+
f"gua sync-once: {exc}; error heartbeat push also failed: {push_exc}",
|
|
615
|
+
file=sys.stderr,
|
|
616
|
+
)
|
|
617
|
+
return 1
|
|
618
|
+
print(
|
|
619
|
+
f"gua sync-once: {exc}; pushed error heartbeat to "
|
|
620
|
+
f"{config.display_name} ({config.server_url})",
|
|
621
|
+
file=sys.stderr,
|
|
622
|
+
)
|
|
623
|
+
return 1
|
|
624
|
+
|
|
625
|
+
|
|
557
626
|
def _cmd_gua_daemon(args: argparse.Namespace) -> int:
|
|
558
627
|
if args.foreground:
|
|
559
628
|
args.display_command = "gua daemon --foreground"
|
|
@@ -598,6 +667,9 @@ def _cmd_gua_start(args: argparse.Namespace) -> int:
|
|
|
598
667
|
"--interval",
|
|
599
668
|
_duration_cli_value(args.interval),
|
|
600
669
|
]
|
|
670
|
+
# cloud sync 옵션을 백그라운드 프로세스로 전파한다.
|
|
671
|
+
if getattr(args, "cloud", False):
|
|
672
|
+
command += ["--cloud", "--config", str(args.config)]
|
|
601
673
|
env = os.environ.copy()
|
|
602
674
|
env[DISPLAY_COMMAND_ENV] = "gua daemon --foreground"
|
|
603
675
|
with log_path.open("ab") as log:
|
|
@@ -712,6 +784,16 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
|
|
712
784
|
return 2
|
|
713
785
|
if is_default_db_path(db_path):
|
|
714
786
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
787
|
+
|
|
788
|
+
# cloud sync 가 켜졌으면 enroll 설정을 *먼저* 검증한다(미enroll 이면 NVML 열기 전에 중단).
|
|
789
|
+
cloud_config = None
|
|
790
|
+
if getattr(args, "cloud", False):
|
|
791
|
+
try:
|
|
792
|
+
cloud_config = load_cloud_config(args.config)
|
|
793
|
+
except CloudConfigError as exc:
|
|
794
|
+
print(f"{display_command}: {exc}", file=sys.stderr)
|
|
795
|
+
return 2
|
|
796
|
+
|
|
715
797
|
tier = NVMLTier()
|
|
716
798
|
try:
|
|
717
799
|
try:
|
|
@@ -721,12 +803,44 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
|
|
721
803
|
return 1
|
|
722
804
|
conn = open_db(db_path)
|
|
723
805
|
try:
|
|
806
|
+
hostname = socket.gethostname() or "unknown"
|
|
724
807
|
host = HostMeta(
|
|
725
|
-
hostname=
|
|
808
|
+
hostname=hostname,
|
|
726
809
|
env_kind=LOCAL_ENV_KIND,
|
|
727
810
|
driver_version=driver,
|
|
728
811
|
first_seen=datetime.now(UTC),
|
|
729
812
|
)
|
|
813
|
+
|
|
814
|
+
# cloud on_tick 후크: local write 이후 latest snapshot 을 board 로 push.
|
|
815
|
+
# 빌드/푸시 실패는 daemon._tick 이 잡아 로그만 남기고 다음 틱을 계속한다.
|
|
816
|
+
on_tick = None
|
|
817
|
+
if cloud_config is not None:
|
|
818
|
+
|
|
819
|
+
def push_snapshot(snap: Snapshot, ts: datetime) -> None:
|
|
820
|
+
# on_tick 은 같은 데몬 스레드에서 tier.collect() 직후 동기로
|
|
821
|
+
# 호출되므로, tier 의 last-collect 상태가 이 snap 과 일치한다.
|
|
822
|
+
collection_status, errors = derive_collection_status(
|
|
823
|
+
snap, process_list_unavailable=tier.last_process_list_unavailable
|
|
824
|
+
)
|
|
825
|
+
payload = build_observation_payload(
|
|
826
|
+
snapshot=snap,
|
|
827
|
+
hostname=hostname,
|
|
828
|
+
driver_version=driver,
|
|
829
|
+
agent_version=__version__,
|
|
830
|
+
observed_at=ts,
|
|
831
|
+
host_id=cloud_config.host_id,
|
|
832
|
+
display_name=cloud_config.display_name,
|
|
833
|
+
collection_status=collection_status,
|
|
834
|
+
errors=errors,
|
|
835
|
+
)
|
|
836
|
+
post_observation(cloud_config, payload)
|
|
837
|
+
|
|
838
|
+
on_tick = push_snapshot
|
|
839
|
+
print(
|
|
840
|
+
f"{display_command}: cloud sync enabled -> "
|
|
841
|
+
f"{cloud_config.display_name} ({cloud_config.server_url})"
|
|
842
|
+
)
|
|
843
|
+
|
|
730
844
|
stop = threading.Event()
|
|
731
845
|
install_signal_handlers(stop)
|
|
732
846
|
run_daemon(
|
|
@@ -737,6 +851,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
|
|
|
737
851
|
lookup=system_user_lookup,
|
|
738
852
|
name_lookup=system_process_name_lookup,
|
|
739
853
|
stop=stop,
|
|
854
|
+
on_tick=on_tick,
|
|
740
855
|
)
|
|
741
856
|
total = conn.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0]
|
|
742
857
|
print(f"\n{args.db}: {total} total gpu_sample rows")
|
|
@@ -21,6 +21,35 @@ from . import SCHEMA_VERSION
|
|
|
21
21
|
CollectionStatus = str # "ok" | "partial" | "error" — 서버가 enum 검증.
|
|
22
22
|
_UNKNOWN = "unknown"
|
|
23
23
|
|
|
24
|
+
# 안정적인 short error code — 서버/board 가 freshness 신호로 표시한다.
|
|
25
|
+
# 새 code 는 여기 한곳에 모아 builder/CLI/daemon 이 같은 문자열을 공유한다.
|
|
26
|
+
ERROR_PROCESS_LIST_UNAVAILABLE = "process_list_unavailable"
|
|
27
|
+
ERROR_NVML_INIT_FAILED = "nvml_init_failed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def derive_collection_status(
|
|
31
|
+
snapshot: Snapshot,
|
|
32
|
+
*,
|
|
33
|
+
process_list_unavailable: bool = False,
|
|
34
|
+
) -> tuple[CollectionStatus, list[str]]:
|
|
35
|
+
"""수집 결과에서 `(collectionStatus, errors)` 를 도출한다.
|
|
36
|
+
|
|
37
|
+
contract 와 같은 곳에 둬서 CLI(`sync-once`)/daemon 두 call site 가 같은
|
|
38
|
+
규칙을 공유한다. builder 의 검증(`partial`/`error` 는 errors ≥1, `ok` 는
|
|
39
|
+
errors 비어야 함)과 일관되게 만든다.
|
|
40
|
+
|
|
41
|
+
- GPU 가 하나라도 수집되고 일부 카드의 process list 만 권한/일시오류로
|
|
42
|
+
비었으면 → `partial` + `process_list_unavailable`. core GPU metric 은
|
|
43
|
+
그대로 push 한다.
|
|
44
|
+
- 그 외(모든 카드 정상, 또는 애초에 GPU 0개여도 push 흐름 자체는 정상) → `ok`.
|
|
45
|
+
|
|
46
|
+
NVML init 자체가 실패해 GPU inventory 가 아예 없는 `error` heartbeat 는
|
|
47
|
+
수집을 못 한 상황이라 이 함수가 아니라 call site 에서 직접 구성한다.
|
|
48
|
+
"""
|
|
49
|
+
if snapshot.gpus and process_list_unavailable:
|
|
50
|
+
return "partial", [ERROR_PROCESS_LIST_UNAVAILABLE]
|
|
51
|
+
return "ok", []
|
|
52
|
+
|
|
24
53
|
|
|
25
54
|
def build_observation_payload(
|
|
26
55
|
*,
|
|
@@ -22,13 +22,16 @@ from datetime import UTC, datetime, timedelta
|
|
|
22
22
|
from typing import TextIO
|
|
23
23
|
|
|
24
24
|
from .db import start_daemon_run, write_snapshot
|
|
25
|
-
from .model import HostMeta, ProcSample
|
|
25
|
+
from .model import HostMeta, ProcSample, Snapshot
|
|
26
26
|
from .summarize import summarize
|
|
27
27
|
from .tier import Tier
|
|
28
28
|
|
|
29
29
|
logger = logging.getLogger(__name__)
|
|
30
30
|
|
|
31
31
|
UserLookup = Callable[[int], str | None]
|
|
32
|
+
# 틱 후크: local write *이후* 의 부가 작업(예: cloud push). 데몬 모듈은 cloud 를
|
|
33
|
+
# 모르고, CLI 가 콜백을 주입한다 — 결합도 분리.
|
|
34
|
+
OnTick = Callable[[Snapshot, datetime], None]
|
|
32
35
|
|
|
33
36
|
|
|
34
37
|
def _noop_lookup(_pid: int) -> str | None:
|
|
@@ -78,8 +81,13 @@ def _tick(
|
|
|
78
81
|
n: int,
|
|
79
82
|
out: TextIO,
|
|
80
83
|
run_id: int,
|
|
84
|
+
on_tick: OnTick | None = None,
|
|
81
85
|
) -> None:
|
|
82
|
-
"""한 틱: tier.collect → loginuid/process_name 해석 → 적재 → 한 줄 로그.
|
|
86
|
+
"""한 틱: tier.collect → loginuid/process_name 해석 → 적재 → 한 줄 로그.
|
|
87
|
+
|
|
88
|
+
on_tick 이 있으면 local write *이후* 호출한다(예: cloud push). 후크 실패는
|
|
89
|
+
이미 커밋된 local write 와 다음 틱을 막지 않는다 — 로그만 남기고 계속.
|
|
90
|
+
"""
|
|
83
91
|
snap = tier.collect(ts)
|
|
84
92
|
# ProcSample 이 mutable slots — *제자리* 갱신.
|
|
85
93
|
resolve_proc_identities(snap.procs, lookup, name_lookup)
|
|
@@ -89,6 +97,12 @@ def _tick(
|
|
|
89
97
|
ts_short = ts.strftime("%H:%M:%S.") + f"{ts.microsecond // 1000:03d}"
|
|
90
98
|
print(f"Tick {n} ts={ts_short} {classes}", file=out)
|
|
91
99
|
|
|
100
|
+
if on_tick is not None:
|
|
101
|
+
try:
|
|
102
|
+
on_tick(snap, ts)
|
|
103
|
+
except Exception:
|
|
104
|
+
logger.exception("tick %d on_tick hook failed; continuing", n)
|
|
105
|
+
|
|
92
106
|
|
|
93
107
|
def run_daemon(
|
|
94
108
|
*,
|
|
@@ -101,6 +115,7 @@ def run_daemon(
|
|
|
101
115
|
stop: threading.Event | None = None,
|
|
102
116
|
max_ticks: int | None = None,
|
|
103
117
|
out: TextIO | None = None,
|
|
118
|
+
on_tick: OnTick | None = None,
|
|
104
119
|
) -> int:
|
|
105
120
|
"""ctx 캔슬까지 interval 간격으로 한 틱씩 반복. 적재한 틱 총 수 반환.
|
|
106
121
|
|
|
@@ -135,7 +150,7 @@ def run_daemon(
|
|
|
135
150
|
run_id = start_daemon_run(db, datetime.now(UTC), interval)
|
|
136
151
|
|
|
137
152
|
try:
|
|
138
|
-
_tick(tier, db, host, lookup, name_lookup, datetime.now(UTC), n, out, run_id)
|
|
153
|
+
_tick(tier, db, host, lookup, name_lookup, datetime.now(UTC), n, out, run_id, on_tick)
|
|
139
154
|
except Exception:
|
|
140
155
|
logger.exception("tick %d failed; continuing", n)
|
|
141
156
|
n += 1
|
|
@@ -63,6 +63,10 @@ class NVMLTier:
|
|
|
63
63
|
self._nvml: Any | None = None # pynvml ModuleType
|
|
64
64
|
self._initialized = False
|
|
65
65
|
self._process_list_warning_uuids: set[str] = set()
|
|
66
|
+
# 가장 최근 collect() 에서 process list 를 읽지 못한 GPU UUID 들.
|
|
67
|
+
# warning 집합(반복 로그 억제용, 누적)과 달리 *틱마다 리셋* 되어
|
|
68
|
+
# 그 틱의 collectionStatus(partial) 판정에 쓰인다.
|
|
69
|
+
self._last_process_list_unavailable_uuids: set[str] = set()
|
|
66
70
|
|
|
67
71
|
def __enter__(self) -> NVMLTier:
|
|
68
72
|
return self
|
|
@@ -90,6 +94,7 @@ class NVMLTier:
|
|
|
90
94
|
|
|
91
95
|
gpus: list[GPUSample] = []
|
|
92
96
|
procs: list[ProcSample] = []
|
|
97
|
+
self._last_process_list_unavailable_uuids = set() # 이 틱 기준으로 리셋.
|
|
93
98
|
count = nvml.nvmlDeviceGetCount()
|
|
94
99
|
for i in range(count):
|
|
95
100
|
h = nvml.nvmlDeviceGetHandleByIndex(i)
|
|
@@ -114,6 +119,8 @@ class NVMLTier:
|
|
|
114
119
|
try:
|
|
115
120
|
running = nvml.nvmlDeviceGetComputeRunningProcesses(h)
|
|
116
121
|
except nvml.NVMLError as e:
|
|
122
|
+
# 이 틱의 partial 판정용 — collectionStatus 에 반영된다.
|
|
123
|
+
self._last_process_list_unavailable_uuids.add(uuid)
|
|
117
124
|
if uuid not in self._process_list_warning_uuids:
|
|
118
125
|
logger.warning(
|
|
119
126
|
"NVML process list unavailable for %s; idle-held classification "
|
|
@@ -141,6 +148,15 @@ class NVMLTier:
|
|
|
141
148
|
)
|
|
142
149
|
return Snapshot(gpus=gpus, procs=procs)
|
|
143
150
|
|
|
151
|
+
@property
|
|
152
|
+
def last_process_list_unavailable(self) -> bool:
|
|
153
|
+
"""가장 최근 collect() 에서 한 카드라도 process list 를 못 읽었는지.
|
|
154
|
+
|
|
155
|
+
True 면 core GPU metric 은 수집됐지만 일부 카드의 process 목록이
|
|
156
|
+
권한/일시오류로 비었다는 뜻 — cloud push 는 `partial` 로 보낸다.
|
|
157
|
+
"""
|
|
158
|
+
return bool(self._last_process_list_unavailable_uuids)
|
|
159
|
+
|
|
144
160
|
@staticmethod
|
|
145
161
|
def _read_temperature(nvml: Any, handle: Any) -> int | None:
|
|
146
162
|
"""GPU 온도(°C). 일부 카드/드라이버는 미지원 — 실패 시 None (optional metric)."""
|
|
@@ -12,6 +12,7 @@ from gpu_usage_audit.__main__ import gua_main
|
|
|
12
12
|
from gpu_usage_audit.cloud.client import CloudError
|
|
13
13
|
from gpu_usage_audit.cloud.config import CloudConfig, load_cloud_config, save_cloud_config
|
|
14
14
|
from gpu_usage_audit.model import GPUSample, Snapshot
|
|
15
|
+
from gpu_usage_audit.nvml import NVMLNotAvailableError
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def _config() -> CloudConfig:
|
|
@@ -241,6 +242,138 @@ def test_sync_once_unbuildable_payload_keeps_local_write_without_pushing(
|
|
|
241
242
|
conn.close()
|
|
242
243
|
|
|
243
244
|
|
|
245
|
+
def test_sync_once_emits_partial_when_process_list_unavailable(
|
|
246
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
247
|
+
tmp_path: Path,
|
|
248
|
+
capsys: pytest.CaptureFixture[str],
|
|
249
|
+
) -> None:
|
|
250
|
+
config_path = tmp_path / "cloud.json"
|
|
251
|
+
save_cloud_config(_config(), config_path)
|
|
252
|
+
db_path = tmp_path / "gua.db"
|
|
253
|
+
|
|
254
|
+
class _PartialTier:
|
|
255
|
+
# core GPU metric 은 수집했지만 한 카드의 process list 가 권한 부족.
|
|
256
|
+
def probe(self) -> str:
|
|
257
|
+
return "560.35.05"
|
|
258
|
+
|
|
259
|
+
def collect(self, _ts: object) -> Snapshot:
|
|
260
|
+
return Snapshot(
|
|
261
|
+
gpus=[
|
|
262
|
+
GPUSample(uuid="GPU-0", util_pct=10, index=0, name="GPU", memory_total_mb=1000)
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def last_process_list_unavailable(self) -> bool:
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
def close(self) -> None:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _PartialTier)
|
|
274
|
+
pushed: dict[str, Any] = {}
|
|
275
|
+
|
|
276
|
+
def fake_post(config: CloudConfig, payload: dict[str, Any], **_kw: Any) -> dict[str, Any]:
|
|
277
|
+
pushed["payload"] = payload
|
|
278
|
+
return {}
|
|
279
|
+
|
|
280
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
|
|
281
|
+
|
|
282
|
+
# --fake 없이 실 NVML 경로를 탄다 (위에서 NVMLTier 를 monkeypatch).
|
|
283
|
+
rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(db_path)])
|
|
284
|
+
|
|
285
|
+
assert rc == 0
|
|
286
|
+
payload = pushed["payload"]
|
|
287
|
+
assert payload["collectionStatus"] == "partial"
|
|
288
|
+
assert payload["errors"] == ["process_list_unavailable"]
|
|
289
|
+
# 핵심: GPU 데이터는 그대로 push 됐다.
|
|
290
|
+
assert len(payload["gpus"]) == 1
|
|
291
|
+
out = capsys.readouterr().out
|
|
292
|
+
assert "partial: process_list_unavailable" in out
|
|
293
|
+
# local write 도 보존.
|
|
294
|
+
conn = sqlite3.connect(db_path)
|
|
295
|
+
try:
|
|
296
|
+
assert conn.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0] == 1
|
|
297
|
+
finally:
|
|
298
|
+
conn.close()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_sync_once_pushes_error_heartbeat_when_nvml_init_fails(
|
|
302
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
303
|
+
tmp_path: Path,
|
|
304
|
+
capsys: pytest.CaptureFixture[str],
|
|
305
|
+
) -> None:
|
|
306
|
+
config_path = tmp_path / "cloud.json"
|
|
307
|
+
save_cloud_config(_config(), config_path)
|
|
308
|
+
db_path = tmp_path / "gua.db"
|
|
309
|
+
|
|
310
|
+
class _DeadTier:
|
|
311
|
+
# 드라이버를 잃은 host — NVML init 자체가 실패.
|
|
312
|
+
def probe(self) -> str:
|
|
313
|
+
raise NVMLNotAvailableError("the NVIDIA driver is not loaded")
|
|
314
|
+
|
|
315
|
+
def collect(self, _ts: object) -> Snapshot: # pragma: no cover - 도달 안 함
|
|
316
|
+
raise AssertionError("collect must not run after probe failure")
|
|
317
|
+
|
|
318
|
+
def close(self) -> None:
|
|
319
|
+
pass
|
|
320
|
+
|
|
321
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _DeadTier)
|
|
322
|
+
pushed: dict[str, Any] = {}
|
|
323
|
+
|
|
324
|
+
def fake_post(config: CloudConfig, payload: dict[str, Any], **_kw: Any) -> dict[str, Any]:
|
|
325
|
+
pushed["payload"] = payload
|
|
326
|
+
return {}
|
|
327
|
+
|
|
328
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
|
|
329
|
+
|
|
330
|
+
rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(db_path)])
|
|
331
|
+
|
|
332
|
+
# error heartbeat 는 보냈지만 수집 실패라 non-zero exit.
|
|
333
|
+
assert rc == 1
|
|
334
|
+
payload = pushed["payload"]
|
|
335
|
+
assert payload["collectionStatus"] == "error"
|
|
336
|
+
assert payload["errors"] == ["nvml_init_failed"]
|
|
337
|
+
assert payload["gpus"] == []
|
|
338
|
+
err = capsys.readouterr().err
|
|
339
|
+
assert "pushed error heartbeat" in err
|
|
340
|
+
assert "driver is not loaded" in err
|
|
341
|
+
# 데이터가 없으므로 local DB 는 쓰지 않는다.
|
|
342
|
+
assert not db_path.exists()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_sync_once_error_heartbeat_push_failure_reports_both(
|
|
346
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
347
|
+
tmp_path: Path,
|
|
348
|
+
capsys: pytest.CaptureFixture[str],
|
|
349
|
+
) -> None:
|
|
350
|
+
config_path = tmp_path / "cloud.json"
|
|
351
|
+
save_cloud_config(_config(), config_path)
|
|
352
|
+
|
|
353
|
+
class _DeadTier:
|
|
354
|
+
def probe(self) -> str:
|
|
355
|
+
raise NVMLNotAvailableError("the NVIDIA driver is not loaded")
|
|
356
|
+
|
|
357
|
+
def collect(self, _ts: object) -> Snapshot: # pragma: no cover
|
|
358
|
+
raise AssertionError
|
|
359
|
+
|
|
360
|
+
def close(self) -> None:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.NVMLTier", _DeadTier)
|
|
364
|
+
|
|
365
|
+
def fake_post(*_args: Any, **_kw: Any) -> dict[str, Any]:
|
|
366
|
+
raise CloudError("could not reach GUA Board server: connection refused")
|
|
367
|
+
|
|
368
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.post_observation", fake_post)
|
|
369
|
+
|
|
370
|
+
rc = gua_main(["sync-once", "--config", str(config_path), "--db", str(tmp_path / "gua.db")])
|
|
371
|
+
|
|
372
|
+
assert rc == 1
|
|
373
|
+
err = capsys.readouterr().err
|
|
374
|
+
assert "error heartbeat push also failed" in err
|
|
375
|
+
|
|
376
|
+
|
|
244
377
|
def test_sync_once_creates_missing_db_parent_dir(
|
|
245
378
|
monkeypatch: pytest.MonkeyPatch,
|
|
246
379
|
tmp_path: Path,
|
|
@@ -273,3 +406,66 @@ def test_sync_once_without_enrollment_exits_2(
|
|
|
273
406
|
)
|
|
274
407
|
assert rc == 2
|
|
275
408
|
assert "run `gua enroll`" in capsys.readouterr().err
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ── daemon --cloud ───────────────────────────────────────────────
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def test_daemon_cloud_without_enrollment_exits_2(
|
|
415
|
+
tmp_path: Path,
|
|
416
|
+
capsys: pytest.CaptureFixture[str],
|
|
417
|
+
) -> None:
|
|
418
|
+
# --cloud 인데 enroll 안 됨 → NVML 열기 *전에* 설정 검증 실패로 종료.
|
|
419
|
+
rc = gua_main(
|
|
420
|
+
[
|
|
421
|
+
"daemon",
|
|
422
|
+
"--foreground",
|
|
423
|
+
"--cloud",
|
|
424
|
+
"--db",
|
|
425
|
+
str(tmp_path / "gua.db"),
|
|
426
|
+
"--config",
|
|
427
|
+
str(tmp_path / "absent.json"),
|
|
428
|
+
]
|
|
429
|
+
)
|
|
430
|
+
assert rc == 2
|
|
431
|
+
assert "run `gua enroll`" in capsys.readouterr().err
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_daemon_start_propagates_cloud_flags_to_background(
|
|
435
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
436
|
+
tmp_path: Path,
|
|
437
|
+
) -> None:
|
|
438
|
+
# 백그라운드 spawn 커맨드에 --cloud/--config 가 실려야 데몬이 push 한다.
|
|
439
|
+
captured: dict[str, Any] = {}
|
|
440
|
+
|
|
441
|
+
class FakePopen:
|
|
442
|
+
def __init__(self, command: list[str], **_kwargs: Any) -> None:
|
|
443
|
+
captured["command"] = command
|
|
444
|
+
self.pid = 4242
|
|
445
|
+
|
|
446
|
+
def poll(self) -> int | None:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.subprocess.Popen", FakePopen)
|
|
450
|
+
monkeypatch.setattr("gpu_usage_audit.__main__.time.sleep", lambda *_a, **_k: None)
|
|
451
|
+
|
|
452
|
+
config_path = tmp_path / "cloud.json"
|
|
453
|
+
rc = gua_main(
|
|
454
|
+
[
|
|
455
|
+
"daemon",
|
|
456
|
+
"--cloud",
|
|
457
|
+
"--db",
|
|
458
|
+
str(tmp_path / "gua.db"),
|
|
459
|
+
"--config",
|
|
460
|
+
str(config_path),
|
|
461
|
+
"--pid-file",
|
|
462
|
+
str(tmp_path / "daemon.pid"),
|
|
463
|
+
"--log-file",
|
|
464
|
+
str(tmp_path / "daemon.log"),
|
|
465
|
+
]
|
|
466
|
+
)
|
|
467
|
+
assert rc == 0
|
|
468
|
+
command = captured["command"]
|
|
469
|
+
assert "--cloud" in command
|
|
470
|
+
assert "--config" in command
|
|
471
|
+
assert str(config_path) in command
|
|
@@ -7,10 +7,15 @@ from typing import Any
|
|
|
7
7
|
|
|
8
8
|
import pytest
|
|
9
9
|
|
|
10
|
-
from gpu_usage_audit.cloud.snapshot import
|
|
10
|
+
from gpu_usage_audit.cloud.snapshot import (
|
|
11
|
+
build_observation_payload,
|
|
12
|
+
derive_collection_status,
|
|
13
|
+
)
|
|
11
14
|
from gpu_usage_audit.model import GPUSample, ProcSample, Snapshot
|
|
12
15
|
from gpu_usage_audit.tier import FakeTier
|
|
13
16
|
|
|
17
|
+
_GPU = GPUSample(uuid="GPU-0", util_pct=0, index=0, name="GPU", memory_total_mb=1000)
|
|
18
|
+
|
|
14
19
|
OBSERVED_AT = datetime(2026, 6, 17, 4, 0, 0, tzinfo=UTC)
|
|
15
20
|
|
|
16
21
|
|
|
@@ -142,6 +147,31 @@ def test_collection_status_error_requires_errors() -> None:
|
|
|
142
147
|
_build(Snapshot(), collection_status="ok", errors=["boom"])
|
|
143
148
|
|
|
144
149
|
|
|
150
|
+
# ── derive_collection_status ─────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_derive_status_ok_when_no_degradation() -> None:
|
|
154
|
+
snap = Snapshot(gpus=[_GPU])
|
|
155
|
+
assert derive_collection_status(snap) == ("ok", [])
|
|
156
|
+
assert derive_collection_status(snap, process_list_unavailable=False) == ("ok", [])
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def test_derive_status_partial_when_process_list_unavailable() -> None:
|
|
160
|
+
snap = Snapshot(gpus=[_GPU])
|
|
161
|
+
status, errors = derive_collection_status(snap, process_list_unavailable=True)
|
|
162
|
+
assert status == "partial"
|
|
163
|
+
assert errors == ["process_list_unavailable"]
|
|
164
|
+
# builder 검증을 그대로 통과해야 한다 (partial 은 errors ≥1).
|
|
165
|
+
payload = _build(snap, collection_status=status, errors=errors)
|
|
166
|
+
assert payload["collectionStatus"] == "partial"
|
|
167
|
+
assert payload["errors"] == ["process_list_unavailable"]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_derive_status_partial_flag_ignored_when_no_gpus() -> None:
|
|
171
|
+
# GPU 가 0개면 partial 로 판정할 카드가 없다 — ok.
|
|
172
|
+
assert derive_collection_status(Snapshot(), process_list_unavailable=True) == ("ok", [])
|
|
173
|
+
|
|
174
|
+
|
|
145
175
|
def test_fake_tier_snapshot_builds_valid_payload() -> None:
|
|
146
176
|
# FakeTier 출력이 그대로 contract 모양으로 변환되는지 (sync-once --fake 기반).
|
|
147
177
|
snap = FakeTier().collect(OBSERVED_AT)
|
|
@@ -13,7 +13,7 @@ import pytest
|
|
|
13
13
|
|
|
14
14
|
from gpu_usage_audit.daemon import run_daemon
|
|
15
15
|
from gpu_usage_audit.db import open_db
|
|
16
|
-
from gpu_usage_audit.model import HostMeta
|
|
16
|
+
from gpu_usage_audit.model import HostMeta, Snapshot
|
|
17
17
|
from gpu_usage_audit.tier import FakeTier
|
|
18
18
|
|
|
19
19
|
INTERVAL = timedelta(milliseconds=20)
|
|
@@ -110,3 +110,51 @@ def test_run_daemon_lookup_resolves_loginuid(db: sqlite3.Connection, host: HostM
|
|
|
110
110
|
assert 1234 not in lookup_calls
|
|
111
111
|
assert 5678 not in lookup_calls
|
|
112
112
|
assert 9999 in lookup_calls
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_run_daemon_invokes_on_tick_after_local_write(
|
|
116
|
+
db: sqlite3.Connection, host: HostMeta
|
|
117
|
+
) -> None:
|
|
118
|
+
# on_tick 은 매 틱 local write *이후* 호출된다(cloud push 가 얹히는 자리).
|
|
119
|
+
calls: list[int] = []
|
|
120
|
+
|
|
121
|
+
def on_tick(snap: Snapshot, _ts: datetime) -> None:
|
|
122
|
+
# 콜백 시점엔 이미 이번 틱이 DB 에 기록돼 있어야 한다.
|
|
123
|
+
calls.append(db.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0])
|
|
124
|
+
assert len(snap.gpus) == 3 # FakeTier 의 스냅샷이 그대로 전달된다.
|
|
125
|
+
|
|
126
|
+
n = run_daemon(
|
|
127
|
+
tier=FakeTier(),
|
|
128
|
+
db=db,
|
|
129
|
+
host=host,
|
|
130
|
+
interval=INTERVAL,
|
|
131
|
+
max_ticks=3,
|
|
132
|
+
out=io.StringIO(),
|
|
133
|
+
on_tick=on_tick,
|
|
134
|
+
)
|
|
135
|
+
assert n == 3
|
|
136
|
+
# 매 틱 호출되고, 호출 시점의 누적 행 수는 3, 6, 9 (틱당 3 GPU).
|
|
137
|
+
assert calls == [3, 6, 9]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_run_daemon_continues_when_on_tick_raises(db: sqlite3.Connection, host: HostMeta) -> None:
|
|
141
|
+
# on_tick(예: cloud push) 실패는 local write 와 다음 틱을 막지 않는다.
|
|
142
|
+
attempts: list[int] = []
|
|
143
|
+
|
|
144
|
+
def boom(_snap: Snapshot, _ts: datetime) -> None:
|
|
145
|
+
attempts.append(1)
|
|
146
|
+
raise RuntimeError("cloud push failed")
|
|
147
|
+
|
|
148
|
+
n = run_daemon(
|
|
149
|
+
tier=FakeTier(),
|
|
150
|
+
db=db,
|
|
151
|
+
host=host,
|
|
152
|
+
interval=INTERVAL,
|
|
153
|
+
max_ticks=3,
|
|
154
|
+
out=io.StringIO(),
|
|
155
|
+
on_tick=boom,
|
|
156
|
+
)
|
|
157
|
+
assert n == 3
|
|
158
|
+
assert len(attempts) == 3 # 매 틱 호출(예외에도 멈추지 않음).
|
|
159
|
+
# local write 는 전부 보존: 3 틱 * 3 GPU = 9 행.
|
|
160
|
+
assert db.execute("SELECT COUNT(*) FROM gpu_sample").fetchone()[0] == 9
|
|
@@ -235,6 +235,41 @@ def test_collect_handles_per_device_running_processes_error(
|
|
|
235
235
|
assert snap.procs == []
|
|
236
236
|
|
|
237
237
|
|
|
238
|
+
def test_last_process_list_unavailable_tracks_per_tick(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
239
|
+
fake = _make_mock_pynvml(
|
|
240
|
+
driver="x",
|
|
241
|
+
gpus=[{"uuid": "GPU-x", "util": 0, "procs": [{"pid": 1, "mem": 1024 * 1024}]}],
|
|
242
|
+
)
|
|
243
|
+
monkeypatch.setitem(sys.modules, "pynvml", fake)
|
|
244
|
+
|
|
245
|
+
def _raise(_h: Any) -> Any:
|
|
246
|
+
raise fake.NVMLError("permission denied")
|
|
247
|
+
|
|
248
|
+
def _ok(_h: Any) -> Any:
|
|
249
|
+
return [SimpleNamespace(pid=1, usedGpuMemory=1024 * 1024)]
|
|
250
|
+
|
|
251
|
+
with NVMLTier() as tier:
|
|
252
|
+
tier.probe()
|
|
253
|
+
# 정상 collect → process list 가용.
|
|
254
|
+
tier.collect(TS)
|
|
255
|
+
clean = tier.last_process_list_unavailable
|
|
256
|
+
|
|
257
|
+
# process list 가 실패하는 틱 → True (core GPU metric 은 유지).
|
|
258
|
+
fake.nvmlDeviceGetComputeRunningProcesses = MagicMock(side_effect=_raise)
|
|
259
|
+
snap = tier.collect(TS)
|
|
260
|
+
degraded = tier.last_process_list_unavailable
|
|
261
|
+
|
|
262
|
+
# 다시 정상으로 돌아오면 *틱마다 리셋* 된다.
|
|
263
|
+
fake.nvmlDeviceGetComputeRunningProcesses = MagicMock(side_effect=_ok)
|
|
264
|
+
tier.collect(TS)
|
|
265
|
+
recovered = tier.last_process_list_unavailable
|
|
266
|
+
|
|
267
|
+
assert clean is False
|
|
268
|
+
assert degraded is True # core metric 은 유지된 채 partial 신호만 켜진다.
|
|
269
|
+
assert [g.uuid for g in snap.gpus] == ["GPU-x"]
|
|
270
|
+
assert recovered is False
|
|
271
|
+
|
|
272
|
+
|
|
238
273
|
def test_probe_translates_nvml_error_to_friendly(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
239
274
|
fake = _make_mock_pynvml(driver="x", gpus=[])
|
|
240
275
|
fake.nvmlInit = MagicMock(side_effect=fake.NVMLError("localized message", value=9))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gpu_usage_audit-1.1.0 → gpu_usage_audit-1.3.0}/docs/work-specs/0001-gua-board-cloud-sync.ko.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|