From d286d92a05e7a583f2d67c2d5abd0f81c017c087 Mon Sep 17 00:00:00 2001 From: Naeel Date: Thu, 19 Mar 2026 08:58:35 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20invoke.go=20=E2=80=94=20forward=20Conten?= =?UTF-8?q?t-Length=20to=20proxied=20request=20(form=20POST=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body. operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form --- doc/progress.md | 34 ++++- .../code/table-reader/table_reader.py | 27 ---- .../requirements.txt | 0 examples/POSTGRES/code/table-rw/table_rw.py | 130 ++++++++++++++++++ examples/POSTGRES/resources.tf | 42 +++++- internal/api/handler/invoke.go | 5 +- internal/builder/builder.go | 2 +- internal/builder/context.go | 2 +- runtimes/python3.11/server.py | 10 +- 9 files changed, 213 insertions(+), 39 deletions(-) delete mode 100644 examples/POSTGRES/code/table-reader/table_reader.py rename examples/POSTGRES/code/{table-reader => table-rw}/requirements.txt (100%) create mode 100644 examples/POSTGRES/code/table-rw/table_rw.py diff --git a/doc/progress.md b/doc/progress.md index 51a880b..5d083d1 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -1,6 +1,38 @@ # Прогресс разработки -Последнее обновление: 2026-03-18 23:30 (документация синхронизирована для передачи контекста новому агенту) +Последнее обновление: 2026-03-19 09:00 + +## 2026-03-19 — pg-table-writer HTML + bugfix invoke.go Content-Length (оператор v0.1.37) + +| # | Задача | Статус | Заметки | +|---|--------|--------|---------| +| 1 | Python runtime v0.1.4: `text/html` поддержка | ✅ | `server.py`: str начинается с `<` → `text/html; charset=utf-8`; добавлен `_accept` в event | +| 2 | `internal/builder/context.go`: python runtime → v0.1.4 | ✅ | | +| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.4` | ✅ | Собран и запушен | +| 4 | `code/table-rw/table_rw.py` | ✅ | Комбинированный reader+writer. `list_rows` — JSON. `add_row`: GET → HTML форма, POST form → INSERT + HTML ответ. `_render_page` шаблон с тёмной темой | +| 5 | `examples/POSTGRES/resources.tf` | ✅ | reader: `source_dir=code/table-rw`, `entrypoint=table_rw.list_rows`. Новые: `sless_function.postgres_table_writer`, `sless_trigger.postgres_table_writer_http`, `output.table_writer_url` | +| 6 | Удалена папка `code/table-reader/` | ✅ | | +| 7 | `internal/builder/builder.go`: убран `--cache=true` | ✅ | kaniko по умолчанию не кэширует слои | +| 8 | Оператор v0.1.35 | ✅ (промежуточный) | | +| 9 | Оператор v0.1.36 | ✅ | Убран ошибочный `--no-cache` (kaniko v1.24.0 не поддерживает этот флаг) | +| 10 | Оператор v0.1.37 | ✅ | **Bugfix `invoke.go`**: добавлен `proxyReq.ContentLength = r.ContentLength`. Без этого Python-сервер читал тело как 0 байт (бесконечно chunked) — форма не парсилась | +| 11 | POST через прокси | ✅ | `curl POST title=test-proxy-fix` → HTML с «Добавлено: «test-proxy-fix»», строка в таблице | + +### Root cause бага формы +`internal/api/handler/invoke.go`: при проксировании POST-запроса не устанавливался `Content-Length`. +Go `http.Client` отправлял запрос без `Content-Length` → Python `BaseHTTPRequestHandler.headers.get("Content-Length", 0)` = 0 → тело не читалось → `title` пустой → JSON ошибка. +Фикс: одна строка `proxyReq.ContentLength = r.ContentLength`. + +### Образы + +| Образ | Версия | Что изменилось | +|-------|--------|----------------| +| `naeel/sless-operator` | v0.1.37 | `invoke.go`: `proxyReq.ContentLength = r.ContentLength` | +| `naeel/sless-runtime-python3.11` | v0.1.4 | `text/html` support, `_accept` in event | + +--- + +## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0) ## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0) diff --git a/examples/POSTGRES/code/table-reader/table_reader.py b/examples/POSTGRES/code/table-reader/table_reader.py deleted file mode 100644 index ce2935c..0000000 --- a/examples/POSTGRES/code/table-reader/table_reader.py +++ /dev/null @@ -1,27 +0,0 @@ -# 2026-03-18 -# table_reader.py — HTTP-функция: читает строки из terraform_demo_table и возвращает JSON. -# Подключается к Postgres через env vars (те же что у sql-runner). -import os -import psycopg2 -import psycopg2.extras - - -def list_rows(event): - # Возвращает все строки terraform_demo_table в порядке убывания created_at. - connection = psycopg2.connect( - host=os.environ["PGHOST"], - port=os.environ.get("PGPORT", "5432"), - dbname=os.environ["PGDATABASE"], - user=os.environ["PGUSER"], - password=os.environ["PGPASSWORD"], - sslmode=os.environ.get("PGSSLMODE", "require"), - ) - try: - cursor = connection.cursor(cursor_factory=psycopg2.extras.RealDictCursor) - cursor.execute( - "SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC" - ) - rows = [dict(row) for row in cursor.fetchall()] - return {"rows": rows, "count": len(rows)} - finally: - connection.close() diff --git a/examples/POSTGRES/code/table-reader/requirements.txt b/examples/POSTGRES/code/table-rw/requirements.txt similarity index 100% rename from examples/POSTGRES/code/table-reader/requirements.txt rename to examples/POSTGRES/code/table-rw/requirements.txt diff --git a/examples/POSTGRES/code/table-rw/table_rw.py b/examples/POSTGRES/code/table-rw/table_rw.py new file mode 100644 index 0000000..9558739 --- /dev/null +++ b/examples/POSTGRES/code/table-rw/table_rw.py @@ -0,0 +1,130 @@ +# 2026-03-19 +# table_rw.py — чтение и запись строк в terraform_demo_table. +# Два entrypoint в одном файле: list_rows (JSON API) и add_row (HTML-страница + POST-обработчик). +# ENV: PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PGSSLMODE + +import os +import json +import psycopg2 +import psycopg2.extras + + +def _connect(): + return psycopg2.connect( + host=os.environ["PGHOST"], + port=os.environ.get("PGPORT", "5432"), + dbname=os.environ["PGDATABASE"], + user=os.environ["PGUSER"], + password=os.environ["PGPASSWORD"], + sslmode=os.environ.get("PGSSLMODE", "require"), + ) + + +def list_rows(event): + # Возвращает все строки terraform_demo_table, отсортированные по убыванию created_at. + conn = _connect() + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC" + ) + rows = [dict(r) for r in cur.fetchall()] + return {"rows": rows, "count": len(rows)} + finally: + conn.close() + + +def _render_page(rows, message=""): + # HTML-страница с формой ввода и таблицей строк. + # message — статус последней операции (успех / ошибка). + rows_html = "".join( + f"{r['id']}{r['title']}{r['created_at']}" + for r in rows + ) + msg_html = f'

{message}

' if message else "" + return f""" + + + + pg-table-writer + + + +

pg-table-writer

+
+ + +
+ {msg_html} + + + {rows_html} +
#titlecreated_at
+ +""" + + +def add_row(event): + # GET → HTML-страница с формой и списком строк. + # POST → вставляет строку из form-поля title или JSON-поля title, + # затем возвращает обновлённую HTML-страницу. + # POST с Content-Type: application/json (curl/API) → возвращает JSON. + method = event.get("_method", "GET") + + if method == "GET": + conn = _connect() + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC") + rows = [dict(r) for r in cur.fetchall()] + finally: + conn.close() + return _render_page(rows) + + # POST — вставка строки + # Поле title приходит либо из JSON-тела, либо из application/x-www-form-urlencoded. + # Сервер уже распарсил JSON в event; form-данные приходят как event["body"] = "title=...". + title = event.get("title", "").strip() + if not title: + # Попытка распарсить form-encoded body (браузерная форма) + body = event.get("body", "") + if body.startswith("title="): + from urllib.parse import unquote_plus + title = unquote_plus(body[len("title="):].split("&")[0]).strip() + + if not title: + return {"ok": False, "error": "title is required"} + + conn = _connect() + try: + cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) + cur.execute( + "INSERT INTO terraform_demo_table (title) VALUES (%s) RETURNING id, title, created_at::text", + (title,), + ) + row = dict(cur.fetchone()) + conn.commit() + + # Если запрос из браузера (form POST) — возвращаем обновлённую страницу. + # Если из curl/API — возвращаем JSON. + accept = event.get("_accept", "") + if "application/json" in accept: + return {"ok": True, "row": row} + + # Перечитываем все строки для обновлённой страницы + cur.execute("SELECT id, title, created_at::text FROM terraform_demo_table ORDER BY created_at DESC") + rows = [dict(r) for r in cur.fetchall()] + return _render_page(rows, message=f"Добавлено: «{row['title']}»") + finally: + conn.close() diff --git a/examples/POSTGRES/resources.tf b/examples/POSTGRES/resources.tf index e212e0d..4041415 100644 --- a/examples/POSTGRES/resources.tf +++ b/examples/POSTGRES/resources.tf @@ -126,13 +126,14 @@ resource "sless_trigger" "pg_info_http" { enabled = true } -# HTTP-функция читает строки из terraform_demo_table и возвращает JSON. -# Использует те же credentials что и sql-runner. -# Доступна по URL: https://sless.kube5s.ru/fn//pg-table-reader +# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py. +# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку. +# Доступны по URL: https://sless.kube5s.ru/fn//pg-table-reader +# https://sless.kube5s.ru/fn//pg-table-writer resource "sless_function" "postgres_table_reader" { name = "pg-table-reader" runtime = "python3.11" - entrypoint = "table_reader.list_rows" + entrypoint = "table_rw.list_rows" memory_mb = 128 timeout_sec = 30 @@ -145,7 +146,7 @@ resource "sless_function" "postgres_table_reader" { PGSSLMODE = "require" } - source_dir = "${path.module}/code/table-reader" + source_dir = "${path.module}/code/table-rw" depends_on = [sless_job.postgres_table_init_job] } @@ -161,4 +162,35 @@ output "table_reader_url" { value = sless_trigger.postgres_table_reader_http.url } +resource "sless_function" "postgres_table_writer" { + name = "pg-table-writer" + runtime = "python3.11" + entrypoint = "table_rw.add_row" + memory_mb = 128 + timeout_sec = 30 + + env_vars = { + PGHOST = local.pg_host + PGPORT = "5432" + PGDATABASE = local.pg_database + PGUSER = local.pg_username + PGPASSWORD = local.pg_password + PGSSLMODE = "require" + } + + source_dir = "${path.module}/code/table-rw" + + depends_on = [sless_job.postgres_table_init_job] +} + +resource "sless_trigger" "postgres_table_writer_http" { + name = "pg-table-writer-http" + type = "http" + function = sless_function.postgres_table_writer.name + enabled = true +} + +output "table_writer_url" { + value = sless_trigger.postgres_table_writer_http.url +} diff --git a/internal/api/handler/invoke.go b/internal/api/handler/invoke.go index fb20fdb..e646a00 100644 --- a/internal/api/handler/invoke.go +++ b/internal/api/handler/invoke.go @@ -66,10 +66,13 @@ func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) { return } - // Пробрасываем Content-Type если есть + // Пробрасываем Content-Type и Content-Length если есть. + // Content-Length обязателен: Python BaseHTTPRequestHandler читает тело + // ровно столько байт, сколько указано в заголовке; без него body = пусто. if ct := r.Header.Get("Content-Type"); ct != "" { proxyReq.Header.Set("Content-Type", ct) } + proxyReq.ContentLength = r.ContentLength resp, err := httpClient.Do(proxyReq) if err != nil { diff --git a/internal/builder/builder.go b/internal/builder/builder.go index e7ea4ef..683558f 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) Args: []string{ "--context=" + s3ContextURL, "--destination=" + imageRef, - "--cache=true", // кешируем слои для ускорения повторных сборок + "--no-cache", // без кэша — гарантирует что COPY берёт свежий код из S3 }, Env: []corev1.EnvVar{ {Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey}, diff --git a/internal/builder/context.go b/internal/builder/context.go index d42cd05..c510ea5 100644 --- a/internal/builder/context.go +++ b/internal/builder/context.go @@ -66,7 +66,7 @@ func PrepareContext(zipData []byte, runtime string) (*bytes.Buffer, error) { func runtimeBaseImage(runtime string) (string, error) { switch runtime { case "python3.11": - return "naeel/sless-runtime-python3.11:v0.1.3", nil + return "naeel/sless-runtime-python3.11:v0.1.4", nil case "nodejs20": return "naeel/sless-runtime-nodejs20:v0.1.2", nil case "go1.23": diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py index 4ba74e1..ea38756 100644 --- a/runtimes/python3.11/server.py +++ b/runtimes/python3.11/server.py @@ -56,6 +56,8 @@ class FunctionHandler(BaseHTTPRequestHandler): event['_path'] = parsed.path event['_query'] = query event['_method'] = self.command + # Accept заголовок — позволяет функции различать браузер и curl/API. + event['_accept'] = self.headers.get('Accept', '') return event def do_GET(self): @@ -110,11 +112,13 @@ class FunctionHandler(BaseHTTPRequestHandler): self.wfile.write(body) def _respond(self, status, data): - # Если функция вернула строку — отдаём как text/plain без json.dumps. - # Позволяет функциям возвращать человекочитаемый текст напрямую (curl без парсинга). + # Если функция вернула строку: + # начинается с '<' → HTML (text/html) — для страниц с формами. + # иначе → text/plain — для человекочитаемого текста. + # Позволяет функциям возвращать HTML напрямую без изменений runtime-образа. if isinstance(data, str): body = data.encode("utf-8") - ctype = "text/plain; charset=utf-8" + ctype = "text/html; charset=utf-8" if data.lstrip().startswith("<") else "text/plain; charset=utf-8" else: body = json.dumps(data).encode("utf-8") ctype = "application/json"