fix: invoke.go — forward Content-Length to proxied request (form POST fix)
Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body. operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form
This commit is contained in:
+33
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"<tr><td>{r['id']}</td><td>{r['title']}</td><td>{r['created_at']}</td></tr>"
|
||||
for r in rows
|
||||
)
|
||||
msg_html = f'<p class="msg">{message}</p>' if message else ""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>pg-table-writer</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; max-width: 700px; margin: 40px auto; background: #111; color: #eee; }}
|
||||
h1 {{ color: #7dd3fc; }}
|
||||
form {{ display: flex; gap: 8px; margin-bottom: 24px; }}
|
||||
input[type=text] {{ flex: 1; padding: 8px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 15px; }}
|
||||
button {{ padding: 8px 18px; background: #2563eb; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 15px; }}
|
||||
button:hover {{ background: #1d4ed8; }}
|
||||
table {{ width: 100%; border-collapse: collapse; }}
|
||||
th, td {{ padding: 8px 10px; border-bottom: 1px solid #333; text-align: left; }}
|
||||
th {{ color: #7dd3fc; }}
|
||||
.msg {{ color: #4ade80; margin-bottom: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>pg-table-writer</h1>
|
||||
<form method="POST">
|
||||
<input type="text" name="title" placeholder="Введите строку..." autofocus required>
|
||||
<button type="submit">Добавить</button>
|
||||
</form>
|
||||
{msg_html}
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>title</th><th>created_at</th></tr></thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
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()
|
||||
@@ -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/<namespace>/pg-table-reader
|
||||
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
|
||||
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
|
||||
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
||||
# https://sless.kube5s.ru/fn/<namespace>/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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user