backprop §B.4 + §V.31: image dist ⊥ bawa modul yang diimpor → ModuleNotFoundError (oracle test_images.py)

This commit is contained in:
proitlab committed 2026-08-06 05:36:24 +07:00
1 parent 025f46d399
commit 0b1749a062
3 files changed
+89 -2

No files matched your search

+2 -2
View File
@@ -13,8 +13,8 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan and dist images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`. - Docker Swarm (production stack): images are registry-pushed `git.proit.id/proitlab/databisnisid-web` + `databisnisid-scan` + `databisnisid-dist` — build & push them first (`docker build -t git.proit.id/proitlab/databisnisid-web . && docker push git.proit.id/proitlab/databisnisid-web`, same for the scan and dist images), then from a swarm manager run `docker stack deploy -c docker-compose.yml databisnisid`. Stack = mariadb (internal) + web (gunicorn dashboard, published `:5000`) + scan (`scan_loop.py`, runs `get_voters.py` at `SCAN_HOURS` default `0,8,16`, local timezone `TZ` default `Asia/Jakarta`, plus one scan at container start via `SCAN_RUN_ON_START`) + distribute (`distribute_loop.py`, runs `distribute.py` once daily at `DISTRIBUTE_HOUR` default `10`, key `VEX_BP_PRIVATE_KEY` read from the bind-mounted `/app/.env` → host `/mnt/nfs/server5.saltis.id/data/databisnisid/app/config.env` via `config.py` `load_dotenv`, `:ro`). `mariadb` is pinned by `placement.constraints: node.hostname == server5.saltis.id` because its data lives in the host bind mount `/data/db/mariadb/databisnisid/data` on that node; `web`/`scan`/`distribute` can run on any node and reach it over the overlay network `appnet`. Inspect: `docker stack services databisnisid`, `docker service logs databisnisid_scan`, `docker stack rm databisnisid`. `docker stack deploy` ignores `build:` (images must already be in the registry) and ignores `env_file` (env is inlined with `${VAR}` interpolation from `.env`). The local dev box has no swarm anymore (torn down) — if you re-init one there, the mariadb constraint leaves that task **Pending** since no node is named `server5.saltis.id`.
- Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans. - Web dashboard (read-only, reads the store via `db.py`): production `./venv/bin/gunicorn -c gunicorn.conf.py dashboard:app` → http://127.0.0.1:5000/ (run from repo dir). Easier: `./run.sh` (same command, works from any cwd, `$@` passed through). `gunicorn.conf.py` imports `config` → `.env` honored; `DASH_WORKERS` (default 2) controls workers, `DASH_HOST`/`DASH_PORT` the bind. Dev server (single-process) still works via `./venv/bin/python dashboard.py`. Paged 50/page, sorted `staked DESC`, live owner search (`/api/search`). Data freshness comes from the daily scan run — the dashboard never scans.
- Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded. - Config: all tunables load from env / `.env` via `config.py` (python-dotenv): `VEX_TARGET_BP`, `VEX_API_NODE`, `VEX_DB_PATH`, `VEX_DB_BACKEND`, `VEX_DB_HOST`, `VEX_DB_PORT`, `VEX_DB_USER`, `VEX_DB_PASS`, `VEX_DB_NAME`, `VEX_MIN_STAKED_VEX`, `DASH_PAGE_SIZE`, `DASH_HOST`, `DASH_PORT`, `DASH_WORKERS`, `VEX_STALE_DAYS`, `DATABISNIS_API`, `DASH_LIQUID_TTL`, `VEX_BP_PRIVATE_KEY`, `DISTRIBUTE_HOUR`, `DISTRIBUTE_MAX_ATTEMPTS`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_IDS`. Copy `.env.example` → `.env` to override; `.env` is gitignored. Chain constants (`vexcore`/scope/table) stay hardcoded.
- Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py`. - Syntax check: `node --check get_voters.js`, `./venv/bin/python -m py_compile config.py get_voters.py dashboard.py db.py gunicorn.conf.py scan_loop.py distribute.py distribute_loop.py telegram.py test_get_voters.py test_dashboard.py test_mariadb.py test_distribute.py test_images.py`.
- Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, and `./venv/bin/python test_distribute.py` must all exit 0. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file. - Tests (the verification oracles): `./venv/bin/python test_get_voters.py`, `./venv/bin/python test_dashboard.py`, `./venv/bin/python test_distribute.py`, and `./venv/bin/python test_images.py` must all exit 0. `test_images.py` (V31) is a static check that each image (`Dockerfile`/`Dockerfile.scan`/`Dockerfile.dist`) copies every intra-project module its copied modules import. They mock the network / use a temp DB — the live node is too flaky/slow for a full-scan test. Run after touching the relevant file.
## Key facts ## Key facts
+3
View File
@@ -89,6 +89,7 @@ V27: dashboard read-only; `/history` baca `distribute_*` via `db.py`; ⊥ mutasi
V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer `vexascan.com/transaction/<txid>`; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned V28: web list desktop → kolom AKUN/STAKE/REWARD/VOTE sama lebar (RANK tetap 72px); AKUN kiri; STAKE/REWARD/VOTE TERAKHIR center; tablet → RANK 56 / AKUN 1fr / STAKE 160px / REWARD 1fr / VOTE 1fr; history → runs 6 kolom, payments 4 kolom (AKUN/JUMLAH/STATUS/TXID) hanya run terbaru (max run_id), tanggal run di judul; TXID = link explorer `vexascan.com/transaction/<txid>`; ⊖ MEMO (kolom DB + UI dihapus, migrasi drop kolom); mobile → kartu berlabel ∀ field; HTML `no-store` + stylesheet versioned
V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker) V29: kolom TOTAL REWARD (VEX) per pemilih = Σ `amount` di `distribute_payments` status=`sent` (semua run, sepanjang masa); voter tanpa transfer → `0,0000`; `failed`/`pending` ⊥ dihitung (⊥ dobel hitung saat baris gagal ikut run berikutnya); `/api/search` menyertakan `total_reward`; query list/search memakai LEFT JOIN subquery agregat + ensure schema tabel payments (sekali per worker)
V30: notifikasi Telegram best-effort utk distribusi: `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial + terkirim/total/gagal), `notify_failure` saat exception di `main` (lalu re-raise); no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_IDS` → no-op senyap; gagal kirim → log warning, ⊥ pernah crash distribusi V30: notifikasi Telegram best-effort utk distribusi: `notify_start` saat run nyata dimulai (setelah guard no-op + key siap, ⊥ dry-run), `notify_finish` setelah run tutup (status ok/partial + terkirim/total/gagal), `notify_failure` saat exception di `main` (lalu re-raise); no-op (pemilih kosong/saldo kecil) ⊥ notif; tanpa `TELEGRAM_BOT_TOKEN`/`TELEGRAM_CHAT_IDS` → no-op senyap; gagal kirim → log warning, ⊥ pernah crash distribusi
V31: ∀ image stack (web/scan/dist) → tiap modul .py yang diimpor modul yang di-COPY ikut disalin (⊥ impor intra-proyek yang ⊥ ada di image); oracle `test_images.py` memindai import tiap image → daftar COPY memuat semua impor intra-proyek
## §T — Tasks ## §T — Tasks
id|status|task|cites id|status|task|cites
@@ -125,9 +126,11 @@ T30|x|dashboard desktop: kolom AKUN/STAKE/VOTE sama lebar, AKUN kiri, STAKE+VOTE
T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web T31|x|history payments: hanya run terbaru (max run_id) + kolom TANGGAL pindah ke judul; kolom MEMO dihapus (DB + UI, migrasi drop kolom di ensure_distribute_schema)|V27,V28,I.db,I.web
T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web T32|x|dashboard list: kolom TOTAL REWARD (VEX) = Σ sent per pemilih (LEFT JOIN subquery, status=sent); desktop 5 kolom rata, tablet 5 kolom, kartu mobile + label; TXID history jadi link explorer vexascan|V15,V28,V29,I.web
T33|x|notifikasi Telegram: `telegram.py` (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_IDS`, Dockerfile.dist += telegram.py, oracle|V30,I.env T33|x|notifikasi Telegram: `telegram.py` (send_text best-effort + notify_start/finish/failure), hook di distribute.py (mulai/selesai/gagal; ⊥ dry-run/no-op), config `TELEGRAM_BOT_TOKEN`+`TELEGRAM_CHAT_IDS`, Dockerfile.dist += telegram.py, oracle|V30,I.env
T34|x|image mandiri: distribute_loop self-contained (duplikasi helper scan_loop, ⊥ impor lintas-image) + oracle test_images.py (import intra-proyek ⊆ daftar COPY tiap image)|V31
## §B — Bug log ## §B — Bug log
id|date|cause|fix id|date|cause|fix
B1|2026-08-04|node balikin `staked` sbg string utk sebagian baris → TypeError int÷int|V8 B1|2026-08-04|node balikin `staked` sbg string utk sebagian baris → TypeError int÷int|V8
B2|2026-08-05|`ESCAPE '\'` valid di SQLite tapi memutus literal string MySQL (`'\'` = string tak tertutup) → error 1064 di backend mysql|ganti karakter escape ke `!` (netral di kedua backend), `_escape_like` esc `!`,`%`,`_` (V17) B2|2026-08-05|`ESCAPE '\'` valid di SQLite tapi memutus literal string MySQL (`'\'` = string tak tertutup) → error 1064 di backend mysql|ganti karakter escape ke `!` (netral di kedua backend), `_escape_like` esc `!`,`%`,`_` (V17)
B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []` gagal di mysql|`db.query` bungkus `list()` → tipe seragam (V17) B3|2026-08-05|PyMySQL `fetchall()` → tuple, SQLite → list → asersi `== []` gagal di mysql|`db.query` bungkus `list()` → tipe seragam (V17)
B4|2026-08-06|image dist ⊥ salin `scan_loop.py`, `distribute_loop` impor darinya → ModuleNotFoundError saat smoke container|V31
+84
View File
@@ -0,0 +1,84 @@
"""Uji V31: tiap image stack mandiri — daftar COPY memuat semua impor intra-proyek.
`./venv/bin/python test_images.py` harus exit 0. Memindai `Dockerfile`,
`Dockerfile.scan`, `Dockerfile.dist`: untuk tiap modul .py yang di-COPY,
kumpulkan impor intra-proyek (top-level `import X` / `from X import`) lalu
pastikan semuanya ada di daftar COPY image itu. Menangkap kelas bug B4:
modul yang diimpor modul yang di-COPY tapi ⊥ ikut disalin → ModuleNotFoundError
di container (image ⊥ mandiri).
"""
import os
import re
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
PROJECT = {'config', 'db', 'get_voters', 'dashboard', 'gunicorn_conf',
'scan_loop', 'distribute', 'distribute_loop', 'telegram'}
IMAGES = {
'web': 'Dockerfile',
'scan': 'Dockerfile.scan',
'dist': 'Dockerfile.dist',
}
def copied_modules(dfile):
"""Modul .py yang di-COPY oleh `COPY <files...> ./`."""
mods = set()
with open(os.path.join(ROOT, dfile)) as fh:
for line in fh:
if not line.startswith('COPY '):
continue
tokens = line.split()
if tokens[-1] != './':
continue
for tok in tokens[1:-1]:
if tok.endswith('.py'):
mods.add(tok[:-3])
return mods
def project_imports(mod):
"""Impor intra-proyek dari sumber modul `mod`."""
imports = set()
path = os.path.join(ROOT, mod + '.py')
if not os.path.exists(path):
return imports
with open(path) as fh:
src = fh.read()
for m in re.finditer(r'^\s*import\s+([\w.]+)', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
for m in re.finditer(r'^\s*from\s+([\w.]+)\s+import', src, re.M):
name = m.group(1).split('.')[0]
if name in PROJECT:
imports.add(name)
return imports
def check(tag, cond):
if not cond:
raise AssertionError(f'GAGAL: {tag}')
print(f'ok: {tag}')
def main():
for image, dfile in IMAGES.items():
copied = copied_modules(dfile)
check(f'V31 {image}: ada modul .py di COPY',
bool(copied) and 'config' in copied)
for mod in sorted(copied):
missing = project_imports(mod) - copied
if missing:
raise AssertionError(
f'GAGAL: V31 {image}: {mod} impor {sorted(missing)} '
f'yang ⊥ di-COPY')
print(f'ok: V31 {image}: {mod} impor ⊆ COPY')
print('\nSEMUA UJI PASS')
if __name__ == '__main__':
sys.exit(main())