From 80becd8a52cbeb9f1c843d2dceadce0b3ea3486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Wed, 11 Nov 2026 01:02:22 +0300 Subject: [PATCH 1/2] new ci and tests --- .github/dependabot.yml | 44 ++++++++++ .github/workflows/ci.yml | 170 +++++++++++++++++++++++++++++++++++++-- scripts/api_smoke.py | 90 +++++++++++++++++++++ 3 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 scripts/api_smoke.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d1f92fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,44 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: /backend + schedule: { interval: weekly } + open-pull-requests-limit: 5 + groups: + python-backend: { patterns: ["*"] } + + - package-ecosystem: pip + directory: /ml + schedule: { interval: weekly } + open-pull-requests-limit: 5 + groups: + python-ml: { patterns: ["*"] } + + - package-ecosystem: pip + directory: / + schedule: { interval: weekly } + groups: + python-dev: { patterns: ["*"] } + + - package-ecosystem: npm + directory: /frontend + schedule: { interval: weekly } + open-pull-requests-limit: 5 + groups: + frontend: { patterns: ["*"] } + + - package-ecosystem: github-actions + directory: / + schedule: { interval: weekly } + + - package-ecosystem: docker + directory: /backend + schedule: { interval: weekly } + + - package-ecosystem: docker + directory: /ml + schedule: { interval: weekly } + + - package-ecosystem: docker + directory: /frontend + schedule: { interval: weekly } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1527329..26ca58c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,19 @@ name: CI on: pull_request: branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: lint: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@v4 @@ -25,6 +34,7 @@ jobs: migrations: runs-on: ubuntu-latest + timeout-minutes: 10 services: postgres: @@ -66,6 +76,7 @@ jobs: model: runs-on: ubuntu-latest + timeout-minutes: 10 env: MODEL_PATH: /tmp/model.json @@ -90,13 +101,10 @@ jobs: working-directory: ml run: python baseline_model_check.py ../data/test_FD001.txt --rul ../data/RUL_FD001.txt - - name: Run physics check - working-directory: ml - run: python physics_check.py ../data/train_FD001.txt - pipeline: needs: [migrations, model] runs-on: ubuntu-latest + timeout-minutes: 15 services: postgres: @@ -158,7 +166,153 @@ jobs: EQ=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM equipment") SR=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM sensor_readings") ML=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM ml_predictions") - PH=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM physics_predictions") - RS=$(psql "$PSQL_URL" -tAc "SELECT count(*) FROM ml_predictions WHERE residual <> 0") - echo "equipment=$EQ sensor_readings=$SR ml_predictions=$ML physics_predictions=$PH nonzero_residual=$RS" - [ "$EQ" -eq 20 ] && [ "$SR" -gt 0 ] && [ "$ML" -eq "$SR" ] && [ "$PH" -eq "$SR" ] && [ "$RS" -gt 0 ] + echo "equipment=$EQ sensor_readings=$SR ml_predictions=$ML" + [ "$EQ" -eq 20 ] && [ "$SR" -gt 0 ] && [ "$ML" -eq "$SR" ] + + + frontend: + runs-on: ubuntu-latest + timeout-minutes: 10 + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Cache npm + uses: actions/cache@v4 + with: + path: ~/.npm + key: npm-${{ runner.os }}-${{ hashFiles('frontend/package.json', 'frontend/package-lock.json') }} + restore-keys: npm-${{ runner.os }}- + + - name: Install dependencies + run: | + if [ -f package-lock.json ]; then npm ci; else npm install; fi + + - name: Typecheck and build + run: npm run build + + stack: + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - uses: actions/checkout@v4 + + - name: Create .env + run: | + { + echo "POSTGRES_USER=pdm" + echo "POSTGRES_PASSWORD=pdm" + echo "POSTGRES_DB=pdm" + echo "SIMULATOR_WAIT_TIME=0" + echo "WORKER_POLL_INTERVAL=5" + echo "RUL_WARNING=75" + echo "RUL_CRITICAL=30" + echo "RUL_CLIP=125" + } > .env + + - name: Validate docker-compose.yml + run: docker compose config -q + + - name: Build all images + run: docker compose build + + - name: Start db, backend, frontend + run: docker compose up -d --wait db backend frontend + + - name: Start simulator and worker + run: docker compose up -d simulator worker + + - name: Frontend is served + run: curl -fsS -o /dev/null http://localhost:5173/ + + - name: API smoke + run: python3 scripts/api_smoke.py http://localhost:8000 240 + + - name: Show logs on failure + if: failure() + run: | + docker compose ps -a + docker compose logs --no-color --tail=100 + + - name: Stop stack + if: always() + run: docker compose down -v + + security: + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install pip-audit + run: pip install pip-audit + + - name: Audit Python dependencies + id: pip_audit + continue-on-error: true + run: pip-audit -r backend/requirements.txt -r ml/requirements.txt + + - name: Warn about Python vulnerabilities + if: steps.pip_audit.outcome == 'failure' + run: echo "::warning title=pip-audit::В Python-зависимостях найдены уязвимости, подробности в логе шага Audit Python dependencies" + + - name: Audit npm dependencies + id: npm_audit + continue-on-error: true + working-directory: frontend + run: npm audit --omit=dev --audit-level=high + + - name: Warn about npm vulnerabilities + if: steps.npm_audit.outcome == 'failure' + run: echo "::warning title=npm audit::В npm-зависимостях найдены уязвимости, подробности в логе шага Audit npm dependencies" + + hygiene: + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + pull-requests: read + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Lint workflows (actionlint) + run: docker run --rm -v "$PWD:/repo" --workdir /repo rhysd/actionlint:1.7.7 -color + + - name: Scan for secrets (gitleaks) + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: PR is linked to an issue + if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + if echo "$PR_BODY" | grep -Eiq '(close[sd]?|fix(e[sd])?|resolve[sd]?|refs?) +#[0-9]+'; then + echo "Задача найдена в описании PR" + else + echo "::error title=Нет ссылки на задачу::Добавьте в описание PR строку вида 'Closes #45'" + exit 1 + fi diff --git a/scripts/api_smoke.py b/scripts/api_smoke.py new file mode 100644 index 0000000..20f3a2e --- /dev/null +++ b/scripts/api_smoke.py @@ -0,0 +1,90 @@ +import json +import re +import sys +import time +import urllib.error +import urllib.request + +BASE = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8000").rstrip("/") +WAIT = int(sys.argv[2]) if len(sys.argv) > 2 else 240 + +CONTRACT = [ + ("GET", "/equipment"), + ("GET", "/equipment/{id}"), + ("GET", "/equipment/{id}/status"), + ("GET", "/equipment/{id}/history"), + ("GET", "/equipment/{id}/forecast"), + ("GET", "/alerts"), + ("GET", "/alerts/{id}"), + ("POST", "/predict"), +] + + +def call(method, path): + req = urllib.request.Request(BASE + path, method=method, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return r.status, json.loads(r.read().decode() or "null") + except urllib.error.HTTPError as e: + return e.code, None + except (urllib.error.URLError, TimeoutError, ConnectionError, json.JSONDecodeError): + return None, None + + +def fail(msg): + print(f"ОШИБКА: {msg}") + sys.exit(1) + + +def wait_for(get_value, what): + """Повторяет get_value() раз в 3 с, пока не вернёт не-None, но не дольше WAIT секунд.""" + deadline = time.time() + WAIT + while time.time() < deadline: + value = get_value() + if value is not None: + return value + time.sleep(3) + fail(f"за {WAIT} с не дождались: {what}") + + +def norm(path): + return re.sub(r"\{[^}]+\}", "{}", path) + + +def main(): + schema = wait_for(lambda: (lambda r: r[1] if r[0] == 200 and isinstance(r[1], dict) else None)( + call("GET", "/openapi.json")), "ответа /openapi.json") + paths = {norm(p): {m.upper() for m in methods} for p, methods in schema.get("paths", {}).items()} + + prefix = next((p[: -len("/equipment")] for p in schema["paths"] if norm(p).endswith("/equipment")), None) + if prefix is None: + fail("в OpenAPI нет эндпоинта /equipment") + + missing = [f"{m} {prefix}{p}" for m, p in CONTRACT if m not in paths.get(norm(prefix + p), set())] + if missing: + fail("в API нет эндпоинтов из контракта README:\n " + "\n ".join(missing)) + print(f"OK контракт README: {len(CONTRACT)} эндпоинтов найдено (префикс {prefix or '—'!r})") + + items = wait_for(lambda: (lambda r: r[1] if r[0] == 200 and isinstance(r[1], list) and r[1] else None)( + call("GET", prefix + "/equipment")), "непустого списка оборудования (работают ли симулятор и воркер?)") + if not isinstance(items[0], dict) or "id" not in items[0]: + fail("у элементов /equipment нет поля id") + eq_id = items[0]["id"] + print(f"OK GET {prefix}/equipment: {len(items)} шт.") + + for method, path in CONTRACT: + if method != "GET" or path == "/equipment": + continue + real = prefix + path.replace("{id}", str(eq_id)) + wait_for(lambda real=real: True if call("GET", real)[0] == 200 else None, f"ответа 200 от GET {real}") + print(f"OK GET {real}") + + code, _ = call("GET", f"{prefix}/equipment/999999999") + if code != 404: + fail(f"несуществующее оборудование: ожидали 404, получили {code}") + print("OK несуществующее оборудование -> 404") + print("Смоук-проверка API пройдена") + + +if __name__ == "__main__": + main() From 1f6907164f1005d6c6ae37e28e703a0a8e01d207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Wed, 11 Nov 2026 01:11:45 +0300 Subject: [PATCH 2/2] ... --- .github/dependabot.yml | 44 ------------------------------------------ 1 file changed, 44 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index d1f92fc..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,44 +0,0 @@ -version: 2 -updates: - - package-ecosystem: pip - directory: /backend - schedule: { interval: weekly } - open-pull-requests-limit: 5 - groups: - python-backend: { patterns: ["*"] } - - - package-ecosystem: pip - directory: /ml - schedule: { interval: weekly } - open-pull-requests-limit: 5 - groups: - python-ml: { patterns: ["*"] } - - - package-ecosystem: pip - directory: / - schedule: { interval: weekly } - groups: - python-dev: { patterns: ["*"] } - - - package-ecosystem: npm - directory: /frontend - schedule: { interval: weekly } - open-pull-requests-limit: 5 - groups: - frontend: { patterns: ["*"] } - - - package-ecosystem: github-actions - directory: / - schedule: { interval: weekly } - - - package-ecosystem: docker - directory: /backend - schedule: { interval: weekly } - - - package-ecosystem: docker - directory: /ml - schedule: { interval: weekly } - - - package-ecosystem: docker - directory: /frontend - schedule: { interval: weekly }