Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 162 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,6 +34,7 @@ jobs:

migrations:
runs-on: ubuntu-latest
timeout-minutes: 10

services:
postgres:
Expand Down Expand Up @@ -66,6 +76,7 @@ jobs:

model:
runs-on: ubuntu-latest
timeout-minutes: 10

env:
MODEL_PATH: /tmp/model.json
Expand All @@ -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:
Expand Down Expand Up @@ -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
90 changes: 90 additions & 0 deletions scripts/api_smoke.py
Original file line number Diff line number Diff line change
@@ -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()
Loading