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}
+
+ | # | title | created_at |
+ {rows_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()
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"