From 8c349ee8698e87976193a9810e5ec4798f563f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 23 Aug 2026 06:54:44 +0300 Subject: [PATCH] =?UTF-8?q?History:=20=D0=BF=D0=BB=D0=B0=D0=BD=20VM=20uplo?= =?UTF-8?q?ad-pull=20(=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9,=20=D0=BD=D0=B5=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- History/plan-2026-08-23-vm-upload-pull.md | 300 ++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 History/plan-2026-08-23-vm-upload-pull.md diff --git a/History/plan-2026-08-23-vm-upload-pull.md b/History/plan-2026-08-23-vm-upload-pull.md new file mode 100644 index 0000000..890bf89 --- /dev/null +++ b/History/plan-2026-08-23-vm-upload-pull.md @@ -0,0 +1,300 @@ +# ⛔ НЕ ИСПОЛЬЗОВАТЬ !!! СОЗДАНО ОШИБОЧНО !!! ⛔ + +# ПЛАН: загрузка больших файлов через ВМ (pull) — реализация во Flask + +_Дата: 2026-08-23. Цель: переделать `/upload` так, чтобы большой файл входил через ВМ, а Flask сам тянул его (egress)._ +_Язык: Python/Flask. Основа: `site/app.py` (текущий), паттерн `History/2026-08-21-pattern-vm-buffer-pull.md`._ + +--- + +## 0. Что меняем (кратко) + +Сейчас `/upload` принимает multipart напрямую на managed → тело >64КБ рвётся на входном шлюзе. +Меняем на 3 хопа: + +``` +Браузер ──POST файл──▶ ВМ /upload-lt (nginx, client_max_body_size 100m) → {id, size, url} +Браузер ──POST метаданные (<64КБ)──▶ Flask /upload-vm (managed) +Flask ──GET url (egress, stream)──▶ ВМ /lt-serve/ → читает файл по частям +``` + +Список правок: + +| # | Где | Что | +|---|---|---| +| 1 | ВМ: nginx | `location /upload-lt/` + `client_max_body_size 100m`; `location /lt-serve/` уже есть | +| 2 | ВМ: Flask-приём (`convert_server.py` или отдельный) | endpoint `/upload-lt` (сохранить, вернуть url) + `/lt-serve/` DELETE | +| 3 | managed Flask `app.py` | новый `/upload-vm` (метаданные + egress pull со stream) + `/config` (отдать браузеру URL ВМ) | +| 4 | `templates/index.html` | загрузка в 2 хопа вместо `/upload` | +| 5 | `requirements.txt` | `requests` уже есть — ничего нового | + +--- + +## 1. ВМ: nginx + +В `nginx-contracts.conf` (sites-enabled) — два блока. + +```nginx +# приём файла (без лимита 64КБ), проброс на локальный Flask ВМ (:8766) +location /upload-lt { + proxy_pass http://127.0.0.1:8766; + client_max_body_size 100m; + proxy_request_buffering off; # не буферизовать тело на nginx + proxy_read_timeout 300s; + proxy_send_timeout 300s; +} + +# раздача временного файла (уже добавлен 2026-08-21) +location /lt-serve/ { + alias /var/www/lt-serve/; +} +``` + +CORS для браузера (кросс-домен: браузер на managed, POST на ВМ) — в блоке `/upload-lt`: + +```nginx +add_header Access-Control-Allow-Origin "https://http-12.containerk8s.dev.nubes.ru" always; +add_header Access-Control-Allow-Methods "POST, OPTIONS" always; +add_header Access-Control-Allow-Headers "Content-Type" always; +if ($request_method = OPTIONS) { return 204; } +``` + +_Примечание: `Access-Control-Allow-Origin` — подставить реальный origin managed-сервиса; для диагностики можно `*`._ + +Порядок: `cp nginx-contracts.conf /tmp/nginx-contracts.conf.bak.$(date +%Y%m%d)` → правка → `nginx -t` → `systemctl reload nginx`. + +--- + +## 2. ВМ: приём файла (Flask на ВМ, порт :8766) + +Добавить в `convert_server.py` (или отдельный `upload_server.py`): + +```python +import os, uuid, hmac, hashlib, time +from flask import Flask, request, jsonify + +VM_TOKEN = os.environ.get("VM_TOKEN", "CHANGE_ME") # общий секрет с managed-Flask +SERVE_DIR = "/var/www/lt-serve" # nginx-юзер www-data имеет доступ + +def _sign(fid: str) -> str: + return hmac.new(VM_TOKEN.encode(), fid.encode(), hashlib.sha256).hexdigest() + +@app.route("/upload-lt", methods=["POST"]) +def upload_lt(): + if "file" not in request.files: + return jsonify({"ok": False, "error": "нет поля file"}), 400 + f = request.files["file"] + if not f or f.filename == "": + return jsonify({"ok": False, "error": "пустое имя"}), 400 + + fid = uuid.uuid4().hex + path = os.path.join(SERVE_DIR, fid) + f.save(path) # потоковая запись на диск + size = os.path.getsize(path) + url = f"https://contracts.kube5s.ru/lt-serve/{fid}?t={_sign(fid)}" + return jsonify({"ok": True, "id": fid, "size": size, "url": url}) + +@app.route("/lt-serve/", methods=["DELETE"]) +def delete_lt(fid): + token = request.args.get("t", "") + if not hmac.compare_digest(token, _sign(fid)): + return jsonify({"ok": False, "error": "bad token"}), 403 + path = os.path.join(SERVE_DIR, fid) + if os.path.exists(path): + os.remove(path) + return jsonify({"ok": True}) +``` + +_Каталог `SERVE_DIR` должен существовать и быть writable для пользователя Flask-ВМ (www-data)._ + +--- + +## 3. Managed Flask (`site/app.py`) + +### 3.1 Новый endpoint `/upload-vm` + +```python +import os, hmac, hashlib, time +import requests + +VM_TOKEN = os.environ.get("VM_TOKEN", "CHANGE_ME") +CHUNK = 256 * 1024 # буфер чтения при pull (не грузить файл в память целиком) + +def _sign(fid: str) -> str: + return hmac.new(VM_TOKEN.encode(), fid.encode(), hashlib.sha256).hexdigest() + +@app.route("/upload-vm", methods=["POST"]) +def upload_vm(): + meta = request.get_json(force=True) + fid = meta.get("id") + url = meta.get("url") + name = meta.get("name", "") + expected_size = meta.get("size", 0) + if not fid or not url: + return jsonify({"ok": False, "error": "нужны id и url"}), 400 + + t0 = time.time() + # egress pull — STREAM (не r.content, чтобы не убить под по OOM) + r = requests.get(url, timeout=300, stream=True) + r.raise_for_status() + + md5 = hashlib.md5() + size = 0 + out_path = os.path.join("/tmp", fid) + with open(out_path, "wb") as out: + for chunk in r.iter_content(chunk_size=CHUNK): + if not chunk: + continue + md5.update(chunk) + size += len(chunk) + out.write(chunk) + total_s = time.time() - t0 + + # целостность + ok = (size == expected_size) + + # сообщить ВМ удалить файл + try: + requests.delete(url.split("?")[0] + f"?t={_sign(fid)}", timeout=10) + except Exception: + pass # удалит TTL-очистка на ВМ + + return jsonify({ + "ok": ok, + "id": fid, "name": name, + "size_bytes": size, + "size_mb": round(size / (1024 * 1024), 3), + "md5": md5.hexdigest(), + "pull_ms": round(total_s * 1000, 2), + "speed_mb_s": round(size / (1024 * 1024) / total_s, 3) if total_s > 0 else 0, + }) +``` + +### 3.2 `/config` — отдать браузеру URL ВМ (чтобы не хардкодить в JS) + +```python +VM_UPLOAD_URL = os.environ.get("VM_UPLOAD_URL", "https://contracts.kube5s.ru/upload-lt") + +@app.route("/config") +def config(): + return jsonify({"vm_upload_url": VM_UPLOAD_URL, "version": VERSION}) +``` + +### 3.3 Убрать временное + +- `/fetch` — удалить или оставить как диагностику (на прод не выносить). +- `/upload` (multipart) — оставить для справки/старых тестов, но фронтенд переключить на `/upload-vm`. + +--- + +## 4. Фронтенд (`templates/index.html`) + +Заменить логику `startUpload()`: два XHR вместо одного. + +```js +function startUpload() { + var input = document.getElementById('file'); + var file = input.files[0]; + if (!file) { /* ошибка */ return; } + + var startMs = Date.now(); + + // 1) файл напрямую на ВМ + var fd = new FormData(); + fd.append('file', file, file.name); + + var x1 = new XMLHttpRequest(); + x1.open('POST', VM_UPLOAD_URL); // из /config, либо хардкод + x1.timeout = 600000; + x1.upload.onprogress = function(e) { /* прогресс как сейчас */ }; + + x1.onload = function() { + var up = JSON.parse(x1.responseText); // {ok, id, size, url} + if (!up.ok) { /* ошибка ВМ */ return; } + + // 2) маленький POST метаданных во Flask (same-origin) + var meta = { id: up.id, url: up.url, name: file.name, size: up.size }; + var x2 = new XMLHttpRequest(); + x2.open('POST', '/upload-vm'); + x2.setRequestHeader('Content-Type', 'application/json'); + x2.onload = function() { + var data = JSON.parse(x2.responseText); + renderUploadStats(file, startMs, data); // вывести размер/md5/pull_ms + }; + x2.onerror = function() { /* сетевая ошибка */ }; + x2.send(JSON.stringify(meta)); + }; + x1.onerror = function() { /* сетевая ошибка на ВМ */ }; + x1.send(fd); +} +``` + +Получить `VM_UPLOAD_URL` при загрузке страницы: + +```js +var VM_UPLOAD_URL; +fetch('/config').then(r => r.json()).then(c => { VM_UPLOAD_URL = c.vm_upload_url; }); +``` + +--- + +## 5. Конфиг/переменные (пода managed) + +Задать в ЛК при создании пода (или манифесте): + +| Переменная | Значение | +|---|---| +| `VM_TOKEN` | общий секрет (одинаковый на ВМ и в поде) | +| `VM_UPLOAD_URL` | `https://contracts.kube5s.ru/upload-lt` | + +На ВМ: `VM_TOKEN` в окружении `convert_server.py`/systemd-юнита. + +--- + +## 6. Безопасность (обязательно) + +1. `id` = `uuid4().hex` (128 бит, не угадать). +2. `url` содержит подписанный `t=` — Flask перепроверяет при DELETE. +3. Файл удаляется сразу после pull (DELETE) + TTL-очистка на ВМ (cron: `find /var/www/lt-serve -mmin +60 -delete`). +4. `/lt-serve/` отдаёт файл только по полному url с токеном; без него 404 (опционально проверка в nginx `secure_link`). +5. `VM_TOKEN` — НЕ в коде, только env. + +--- + +## 7. Деплой + +1. Правки ВМ: nginx + `upload_server.py` → `nginx -t` → reload → перезапуск Flask ВМ. +2. Правки managed: `app.py`, `index.html`. +3. Bump версии (`VERSION` в `app.py`) + Dockerfile-тег (`naeel/loadtest:v2.1.0`). +4. `docker build -t naeel/loadtest:v2.1.0 .` → `docker push`. +5. Redeploy пода с env `VM_TOKEN`, `VM_UPLOAD_URL`. + +--- + +## 8. Проверка (по шагам) + +```bash +# 1) приём на ВМ (локально, с --noproxy — иначе прокси Krupski душит) +curl --noproxy '*' -F "file=@/tmp/big.bin" https://contracts.kube5s.ru/upload-lt +# → {"ok":true,"id":"...","size":...,"url":".../lt-serve/...?t=..."} + +# 2) egress pull из пода (managed) +curl "https://http-12.containerk8s.dev.nubes.ru/fetch?url=" + +# 3) полный сценарий через /upload-vm (из пода) +curl -X POST https://http-12.containerk8s.dev.nubes.ru/upload-vm \ + -H 'Content-Type: application/json' \ + -d '{"id":"","url":"","name":"big.bin","size":}' +# → {"ok":true,"md5":"...","pull_ms":...,"speed_mb_s":...} +``` + +Тест-файл: `dd if=/dev/urandom of=/tmp/big.bin bs=1M count=45`. + +--- + +## 9. Ограничения (помнить при прод-варианте) + +1. **OOM** — решено: pull через `stream=True` + `iter_content` (буфер 256КБ), не `r.content`. +2. **1 worker gunicorn** — параллельные pull сериализуются. При росте нагрузки: `--workers 2-4` (следить за памятью). +3. **Входной шлюз ~64КБ** остаётся — все НОВЫЕ входы больших данных только через ВМ. +4. **CORS** — origin managed-сервиса должен быть в `Access-Control-Allow-Origin` ВМ.