diff --git a/.gitea/workflows/flash-aminpa-V1.5.yml b/.gitea/workflows/flash-aminpa-V1.5.yml index da647a4..8028079 100644 --- a/.gitea/workflows/flash-aminpa-V1.5.yml +++ b/.gitea/workflows/flash-aminpa-V1.5.yml @@ -71,8 +71,8 @@ jobs: - name: Configure and verify bootcmd for detected flash run: /usr/bin/python3 deployment/serial/serial_command.py set-bootcmd - - name: Save U-Boot environment before reboot - run: /usr/bin/python3 deployment/serial/serial_command.py saveenv + # - name: Save U-Boot environment before reboot + # run: /usr/bin/python3 deployment/serial/serial_command.py saveenv - name: Reboot hardware after successful flashing run: /usr/bin/python3 deployment/serial/serial_command.py reboot diff --git a/.gitea/workflows/init-test.yml b/.gitea/workflows/init-test.yml new file mode 100644 index 0000000..755fb02 --- /dev/null +++ b/.gitea/workflows/init-test.yml @@ -0,0 +1,24 @@ +name: init test + +on: + workflow_dispatch: + +jobs: + init-test: + runs-on: serial-runner + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Verify ordered boot and Linux console + env: + RUTBS_PASSWORD: ${{ secrets.RUTBS_PASSWORD }} + run: /usr/bin/python3 deployment/serial/init_test.py + + - name: Upload redacted serial log + if: ${{ always() }} + uses: actions/upload-artifact@v3 + with: + name: boot-log + path: artifacts/boot.log + if-no-files-found: error diff --git a/README.md b/README.md index b69e05f..be16f01 100644 --- a/README.md +++ b/README.md @@ -71,3 +71,30 @@ IP میزبان باید از قبل روی کارت شبکهٔ انتخاب‌ workflowهای اصلی امین‌پرداز و ورودی‌های workflowهای کمکی در `.gitea/workflows/` باقی می‌مانند و اسکریپت‌ها را از مسیر جدید اجرا می‌کنند. فایل‌های باینری همچنان در پوشهٔ `firmware/` ریشهٔ مخزن قرار می‌گیرند. + +## تست ترتیب بوت از طریق Serial + +workflow دستی `init test` از فایل `.gitea/workflows/init-test.yml` اجرا می‌شود. +رمز دستگاه را با نام `RUTBS_PASSWORD` در **Actions → Secrets** مخزن تعریف کنید؛ +رمز در سورس یا متغیرهای عمومی قرار نمی‌گیرد. رانر به `pyserial` و دسترسی +به پورت `/dev/ttyUSB0` نیاز دارد. + +برای اجرای مستقیم، رمز را بدون نمایش در ترمینال دریافت کنید: + +```bash +read -rs -p 'RUTBS password: ' RUTBS_PASSWORD +export RUTBS_PASSWORD +python3 deployment/serial/init_test.py +unset RUTBS_PASSWORD +``` + +برای پورت دیگر، گزینهٔ `--port /dev/ttyUSB1` را اضافه کنید. +هنگام اجرای تست، برد باید در پرامپت U-Boot یعنی `=>` باشد. +اسکریپت ابتدا با ارسال Enter پرامپت را تازه می‌کند و پس از تشخیص آن، +دستور `res` را برای ریبوت می‌فرستد؛ سپس مراحل بوت را به‌ترتیب بررسی می‌کند. +این تست باید پیام‌های انتخاب سطح دیباگ و فعال‌سازی کنسول را ببیند و +با workflow دیگری که همان پورت Serial را استفاده می‌کند هم‌زمان اجرا نشود. +از شروع تست، مهلت مشترک مراحل ۱۸۰ ثانیه است. تنها پس از ورود و دریافت +خروجی دقیق `Linux` از `uname -s`، پیام `BOOT TEST: PASS` و کد خروج صفر +برگردانده می‌شود. خطا کد خروج غیرصفر دارد. لاگ Serial با حذف رمز در +`artifacts/boot.log` ذخیره و حتی در شکست تست به‌صورت آرتیفکت بارگذاری می‌شود. diff --git a/deployment/serial/init_test.py b/deployment/serial/init_test.py new file mode 100644 index 0000000..ba19160 --- /dev/null +++ b/deployment/serial/init_test.py @@ -0,0 +1,127 @@ +"""Restart RUTBS from U-Boot and verify the ordered boot and kernel name.""" +import argparse +import fcntl +import os +import re +import time +from pathlib import Path + +import serial + + +STAGES = ( + ('U-Boot prompt', '=>', b'res\r'), + ('kernel', 'Starting kernel ...', None), + ('debug selection', 'Press the [1], [2], [3] or [4] key and hit [enter] to select the debug level', None), + ('console activation', 'Please press Enter to activate this console.', b'\r'), + ('login', 'RUTBS login:', b'root\r'), + ('password', 'Password:', 'password'), + ('shell', 'root@RUTBS:~#', b'uname -s\r'), + ('command result', 'root@RUTBS:~#', None), +) +FAILURE = re.compile(r'Kernel panic|Login incorrect|Login failed|Authentication fail(?:ed|ure)|Access denied', re.I) + + +def run_test(port, password, transcript, deadline): + stage = 0 + pending = '' + observed = '' + # Request a fresh prompt when the existing => predates opening the port. + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError('Overall test timeout') + port.write_timeout = min(2, remaining) + if port.write(b'\r') != 1: + raise RuntimeError('Incomplete serial write') + while time.monotonic() < deadline: + data = port.read(min(port.in_waiting or 1, 4096)) + if not data: + continue + transcript.extend(data) + chunk = data.decode('utf-8', errors='replace').replace('\r', '') + pending += chunk + observed = (observed + chunk)[-8192:] + if FAILURE.search(observed): + raise RuntimeError('Boot or authentication failure detected') + while stage < len(STAGES): + name, token, action = STAGES[stage] + if stage == 0: + match = re.search(r'(?:^|\n)[^\n]*=> *$', pending) + position = pending.rfind('=>') if match else -1 + else: + position = pending.find(token) + if position < 0: + break + before = pending[:position] + pending = pending[position + len(token):] + if stage == len(STAGES) - 1: + lines = [line.strip() for line in before.splitlines() if line.strip()] + if lines and lines[0] == 'uname -s': + lines.pop(0) # A terminal may echo the command. + if lines != ['Linux']: + raise RuntimeError('uname -s output was not exactly Linux') + if time.monotonic() >= deadline: + raise RuntimeError('Overall test timeout') + return + if action is not None: + # Discard pre-existing input: the next prompt must follow our write. + pending = '' + payload = password.encode('utf-8') + b'\r' if action == 'password' else action + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError('Overall test timeout') + port.write_timeout = min(2, remaining) + if port.write(payload) != len(payload): + raise RuntimeError('Incomplete serial write') + stage += 1 + if len(pending) > 65536: + raise RuntimeError('Unexpected excessive serial output in current stage') + raise RuntimeError('Timeout waiting for ' + STAGES[stage][0]) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--port', default='/dev/ttyUSB0') + args = parser.parse_args() + deadline = time.monotonic() + 180 + password = os.environ.get('RUTBS_PASSWORD', '') + transcript = bytearray() + result = 1 + reason = 'Serial connection or configuration error' + log = Path('artifacts/boot.log') + try: + log.parent.mkdir(parents=True, exist_ok=True) + # Never put raw serial bytes on disk or stdout, even during authentication. + log.write_text('', encoding='utf-8') + if not password or any(c in password for c in '\r\n\x00'): + raise RuntimeError('RUTBS_PASSWORD is missing or invalid') + with open('/tmp/uboot-serial.lock', 'w') as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + with serial.Serial(args.port, 115200, timeout=0.1, write_timeout=2, exclusive=True) as port: + run_test(port, password, transcript, deadline) + result = 0 + except RuntimeError as exc: + reason = str(exc) + except Exception: + # Serial/library exceptions can include transmitted data; do not print them. + pass + finally: + content = transcript.decode('utf-8', errors='replace').replace('\r', '') + if password: + content = content.replace(password, '[REDACTED]') + reason = reason.replace(password, '[REDACTED]') + try: + log.write_text(content, encoding='utf-8') + except OSError: + result = 1 + reason = 'Could not save serial log' + if result: + print('BOOT TEST: FAIL') + print(reason) + else: + print('BOOT TEST: PASS') + return result + + +if __name__ == '__main__': + raise SystemExit(main())