fix+docs: FunctionJob label bugfix, job ErrAlreadyExists, python str→text/plain, operator.yaml v0.1.33, progress.md
- controllers/functionjob_controller.go: - PodTemplate labels: functionjob=, function= (k8s 1.27+ удалил job-name=) - getJobPodOutput принимает labelSelector вместо jobName - захват stderr при Failed job; truncateForStatus() helper - terraform/provider/internal/client/client.go: ErrJobAlreadyExists (409 Conflict) - terraform/provider/internal/resources/job_resource.go: при конфликте создания — читаем существующий job - runtimes/python3.11/server.py: str return → text/plain - internal/builder/context.go: python runtime base image → v0.1.3 - deployments/k8s/operator.yaml: image → v0.1.33 - doc/progress.md: добавлены секции FunctionJob bugfix, str→text/plain, web-console v0.2.0
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
|
||||
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
|
||||
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
||||
// При создании FunctionJob:
|
||||
// 1. Ждёт пока Function станет Ready
|
||||
@@ -134,6 +134,13 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
||||
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
|
||||
TTLSecondsAfterFinished: &ttl,
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Labels: map[string]string{
|
||||
"managed-by": "sless",
|
||||
"functionjob": fj.Name,
|
||||
"function": fn.Name,
|
||||
},
|
||||
},
|
||||
Spec: corev1.PodSpec{
|
||||
RestartPolicy: corev1.RestartPolicyNever,
|
||||
// Используем тот же образ что и Deployment функции
|
||||
@@ -194,12 +201,19 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
|
||||
fj.Status.CompletionTime = &now
|
||||
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
|
||||
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
|
||||
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
|
||||
} else if job.Status.Failed > 0 {
|
||||
now := metav1.Now()
|
||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||
fj.Status.CompletionTime = &now
|
||||
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
|
||||
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
|
||||
// Устаревший job-name= удалён в k8s 1.27+, batch.kubernetes.io/job-name= — только с 1.27.
|
||||
podOutput := strings.TrimSpace(getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name))
|
||||
if podOutput == "" || podOutput == "completed successfully" {
|
||||
fj.Status.Message = "job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l functionjob=" + fj.Name
|
||||
} else {
|
||||
fj.Status.Message = "job failed: " + truncateForStatus(podOutput, 2000)
|
||||
}
|
||||
} else {
|
||||
// Job ещё выполняется — перечитаем через 5 секунд
|
||||
if err := r.Status().Update(ctx, fj); err != nil {
|
||||
@@ -213,6 +227,17 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
// truncateForStatus ограничивает длину текста для безопасной записи в status.message.
|
||||
func truncateForStatus(message string, maxLen int) string {
|
||||
if len(message) <= maxLen {
|
||||
return message
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return message[:maxLen]
|
||||
}
|
||||
return message[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
|
||||
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
|
||||
func runtimeRunnerCommand(runtime string) []string {
|
||||
@@ -280,16 +305,22 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
|
||||
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
|
||||
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
|
||||
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
|
||||
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
|
||||
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
||||
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
|
||||
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
|
||||
// выставляется на PodTemplate контроллером и не зависит от версии k8s).
|
||||
// НЕ использовать "job-name=" — этот встроенный лейбл удалён в k8s 1.27+ (у нас 1.34.1).
|
||||
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, labelSelector string) string {
|
||||
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||
LabelSelector: "job-name=" + jobName,
|
||||
LabelSelector: labelSelector,
|
||||
})
|
||||
if err != nil || len(pods.Items) == 0 {
|
||||
return "completed successfully"
|
||||
}
|
||||
// Stdout: true, Stderr: true — собираем оба потока.
|
||||
// Python исключения идут в stderr, runner.py пишет результат в stdout.
|
||||
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
||||
stream, err := req.Stream(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Состав:
|
||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
||||
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
|
||||
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
|
||||
# - Service: ClusterIP :9090 (REST API)
|
||||
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
|
||||
#
|
||||
@@ -72,7 +72,7 @@ spec:
|
||||
containers:
|
||||
- name: operator
|
||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||
image: naeel/sless-operator:v0.1.32
|
||||
image: naeel/sless-operator:v0.1.33
|
||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
|
||||
+60
-6
@@ -1,6 +1,60 @@
|
||||
# Прогресс разработки
|
||||
|
||||
Последнее обновление: 2026-03-18 20:00 (funcs global service v0.1.3, ветка feat/web-console)
|
||||
Последнее обновление: 2026-03-18 22:00 (web-консоль v0.2.0 задеплоена, ветка feat/web-console)
|
||||
|
||||
## 2026-03-18 — web-консоль (оператор v0.1.34 + funcs-service v0.2.0)
|
||||
|
||||
| # | Задача | Статус | Заметки |
|
||||
|---|--------|--------|---------|
|
||||
| 1 | Оператор: `GET /v1/.../functions/{name}/source` | ✅ | `internal/api/handler/source.go` — скачивает tar.gz контекст из S3; извлекает пользовательские файлы (без Dockerfile); возвращает `[{name, content, binary}]` |
|
||||
| 2 | Оператор: `PATCH /v1/.../triggers/{name}` | ✅ | Уже существовал в v0.1.33 — Handler обрабатывает поле `enabled` |
|
||||
| 3 | `internal/api/router.go` — маршрут /source | ✅ | `GET /namespaces/{ns}/functions/{name}/source → h.GetSource` |
|
||||
| 4 | `services/funcs/index.html` — HTML web-консоль | ✅ | Тёмная тема, аккордеоны, highlight.js, кнопки ▶/■ для enable/disable триггеров |
|
||||
| 5 | `services/funcs/main.go` — HTML режим + proxy endpoints | ✅ | `Accept: text/html` → HTML; `GET /funcs/{ns}/source/{fn}` и `PATCH /funcs/{ns}/triggers/{name}` — прокси к оператору через SLESS_SERVICE_TOKEN |
|
||||
| 6 | Backward compat: plain text | ✅ | curl без `Accept: text/html` — старое поведение v0.1.x |
|
||||
| 7 | Оператор v0.1.34 | ✅ | Build+push+deploy в кластер |
|
||||
| 8 | funcs-service v0.2.0 | ✅ | Build+push+deploy в кластер |
|
||||
| 9 | Коммит | ✅ | `bf9f073` — `feat: web-console — HTML UI + source viewer + trigger toggle` |
|
||||
|
||||
### Текущие URL
|
||||
|
||||
```
|
||||
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # браузер → HTML консоль
|
||||
# curl → plain text (backward compat)
|
||||
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info # JSON файлы функции
|
||||
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info # вызов функции
|
||||
```
|
||||
|
||||
### Образы
|
||||
|
||||
| Образ | Версия | Что изменилось |
|
||||
|-------|--------|----------------|
|
||||
| `naeel/sless-operator` | v0.1.34 | GET /source endpoint, HTML web-консоль proxy routes |
|
||||
| `naeel/sless-funcs-service` | v0.2.0 | HTML консоль + source/trigger proxy endpoints |
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-18 — FunctionJob bugfix (functionjob label + stderr capture)
|
||||
|
||||
| # | Задача | Статус | Заметки |
|
||||
|---|--------|--------|---------|
|
||||
| 1 | `functionjob_controller.go`: label на PodTemplate | ✅ | Добавлен `PodTemplate.ObjectMeta.Labels: {managed-by, functionjob, function}` — k8s 1.27+ удалил встроенный `job-name=` лейбл |
|
||||
| 2 | `getJobPodOutput` по `labelSelector` | ✅ | Принимает `"functionjob=<name>"` вместо `jobName`; используется для успешного и упавшего job |
|
||||
| 3 | Захват stderr при ошибке Job | ✅ | При `job.Status.Failed > 0` — берём `podOutput`; если непустой — пишем в `status.Message` (truncate до 2000 символов) |
|
||||
| 4 | `truncateForStatus()` helper | ✅ | Ограничивает длину `status.Message` для безопасной записи в k8s |
|
||||
| 5 | terraform client: `ErrJobAlreadyExists` | ✅ | `client.go`: 409 Conflict → `ErrJobAlreadyExists`; `job_resource.go`: при конфликте создания — читаем существующий job вместо ошибки |
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-18 — Python runtime str→text/plain
|
||||
|
||||
| # | Задача | Статус | Заметки |
|
||||
|---|--------|--------|---------|
|
||||
| 1 | `runtimes/python3.11/server.py` | ✅ | Если функция вернула `str` → `Content-Type: text/plain`, тело без `json.dumps`. Dict/list остаются `application/json` |
|
||||
| 2 | `internal/builder/context.go` | ✅ | Python runtime базовый образ → `v0.1.3` |
|
||||
| 3 | Образ `naeel/sless-runtime-python3.11:v0.1.3` | ✅ | Собран и задеплоен |
|
||||
|
||||
---
|
||||
|
||||
## 2026-03-18 — funcs: глобальный Go сервис + ветка web-console
|
||||
|
||||
@@ -39,11 +93,11 @@ https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
|
||||
|
||||
| # | Задача | Статус | Заметки |
|
||||
|---|--------|--------|---------|
|
||||
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ⏳ | Читает `functions/{ns}/{name}/{ts}.zip` из S3, возвращает JSON с файлами |
|
||||
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ⏳ | Принимает `{"enabled": true/false}`, обновляет Trigger CRD |
|
||||
| 3 | `sless-funcs-service`: HTML страница | ⏳ | Если `Accept: text/html` → отдаёт HTML с JS; аккордеон файлов + кнопки ▶/■ |
|
||||
| 4 | Bump оператора до v0.1.34 | ⏳ | После пп. 1-2 |
|
||||
| 5 | Bump funcs-service до v0.2.0 | ⏳ | После п. 3 |
|
||||
| 1 | Оператор: `GET /v1/namespaces/{ns}/functions/{name}/source` | ✅ | Реализовано — `bf9f073` |
|
||||
| 2 | Оператор: `PATCH /v1/namespaces/{ns}/triggers/{name}` | ✅ | Существовал — проверено, поддерживает `enabled` |
|
||||
| 3 | `sless-funcs-service`: HTML страница | ✅ | `Accept: text/html` → HTML консоль. Аккордеоны + highlight.js + кнопки ▶/■ |
|
||||
| 4 | Bump оператора до v0.1.34 | ✅ | Задеплоен |
|
||||
| 5 | Bump funcs-service до v0.2.0 | ✅ | Задеплоен |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,35 +1,31 @@
|
||||
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
|
||||
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
|
||||
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
|
||||
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
|
||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
|
||||
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
|
||||
#
|
||||
# Env vars:
|
||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
|
||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
|
||||
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
|
||||
#
|
||||
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
|
||||
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
|
||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
|
||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
|
||||
|
||||
import os
|
||||
import requests
|
||||
|
||||
SEP = "─" * 52
|
||||
|
||||
def _short_comment(fn, http_triggers, cron_triggers):
|
||||
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
|
||||
phase = fn.get("phase", "")
|
||||
runtime = fn.get("runtime", "")
|
||||
|
||||
if http_triggers:
|
||||
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
|
||||
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
|
||||
elif cron_triggers:
|
||||
schedule = cron_triggers[0].get("schedule", "?")
|
||||
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
|
||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
|
||||
def _comment(fn, http_trigs, cron_trigs):
|
||||
phase = fn.get("phase", "?")
|
||||
runtime = fn.get("runtime", "?")
|
||||
if http_trigs:
|
||||
active = "активна" if http_trigs[0].get("active") else "неактивна"
|
||||
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
|
||||
elif cron_trigs:
|
||||
schedule = cron_trigs[0].get("schedule", "?")
|
||||
active = "активна" if cron_trigs[0].get("active") else "неактивна"
|
||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
|
||||
else:
|
||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
||||
|
||||
@@ -39,91 +35,60 @@ def list_all(event):
|
||||
namespace = os.environ["SLESS_NAMESPACE"]
|
||||
token = os.environ["SLESS_TOKEN"]
|
||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
||||
|
||||
# Имена функций, которые не должны присутствовать в выводе.
|
||||
# Включает саму себя ("funcs") и служебные функции других примеров.
|
||||
exclude = {
|
||||
n.strip()
|
||||
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
|
||||
if n.strip()
|
||||
}
|
||||
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
fns = requests.get(f"{api_url}/v1/namespaces/{namespace}/functions", headers=headers, timeout=10)
|
||||
trs = requests.get(f"{api_url}/v1/namespaces/{namespace}/triggers", headers=headers, timeout=10)
|
||||
fns.raise_for_status()
|
||||
trs.raise_for_status()
|
||||
|
||||
fns_resp = requests.get(
|
||||
f"{api_url}/v1/namespaces/{namespace}/functions",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
fns_resp.raise_for_status()
|
||||
|
||||
trs_resp = requests.get(
|
||||
f"{api_url}/v1/namespaces/{namespace}/triggers",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
trs_resp.raise_for_status()
|
||||
|
||||
# Индекс триггеров по имени функции
|
||||
triggers_by_fn = {}
|
||||
for tr in trs_resp.json():
|
||||
trig_idx = {}
|
||||
for tr in trs.json():
|
||||
fn_name = tr.get("function") or tr.get("functionRef")
|
||||
if fn_name:
|
||||
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
||||
trig_idx.setdefault(fn_name, []).append(tr)
|
||||
|
||||
result = []
|
||||
for fn in fns_resp.json():
|
||||
items = []
|
||||
for fn in fns.json():
|
||||
name = fn["name"]
|
||||
if name in exclude:
|
||||
continue
|
||||
|
||||
http_triggers = [
|
||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
|
||||
]
|
||||
cron_triggers = [
|
||||
t for t in triggers_by_fn.get(name, []) if t.get("type") == "cron"
|
||||
]
|
||||
is_active = any(
|
||||
t.get("enabled", True) and t.get("active", False)
|
||||
for t in triggers_by_fn.get(name, [])
|
||||
)
|
||||
|
||||
entry = {
|
||||
# "#" — первый ключ: служит визуальным комментарием при pretty-print
|
||||
"#": _short_comment(fn, http_triggers, cron_triggers),
|
||||
"name": name,
|
||||
"runtime": fn.get("runtime"),
|
||||
"phase": fn.get("phase"),
|
||||
"active": is_active,
|
||||
}
|
||||
|
||||
# URL вычисляем из SLESS_EXTERNAL_URL если задан — state может хранить старый домен
|
||||
if http_triggers:
|
||||
if ext_url:
|
||||
entry["url"] = f"{ext_url}/fn/{namespace}/{name}"
|
||||
else:
|
||||
entry["url"] = http_triggers[0].get("url", "")
|
||||
|
||||
if cron_triggers:
|
||||
entry["cron"] = cron_triggers[0].get("schedule", "")
|
||||
|
||||
if fn.get("message"):
|
||||
entry["message"] = fn["message"]
|
||||
|
||||
# created_at и last_built_at — доступны после обновления оператора до v0.1.32+
|
||||
if fn.get("created_at"):
|
||||
entry["created_at"] = fn["created_at"]
|
||||
if fn.get("last_built_at"):
|
||||
entry["last_built_at"] = fn["last_built_at"]
|
||||
|
||||
result.append(entry)
|
||||
http_t = [t for t in trig_idx.get(name, []) if t.get("type") == "http"]
|
||||
cron_t = [t for t in trig_idx.get(name, []) if t.get("type") == "cron"]
|
||||
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
|
||||
items.append((fn, http_t, cron_t, is_active))
|
||||
|
||||
# Сортировка: активные вверх, затем по имени
|
||||
result.sort(key=lambda f: (not f["active"], f["name"]))
|
||||
items.sort(key=lambda x: (not x[3], x[0]["name"]))
|
||||
|
||||
lines = []
|
||||
for fn, http_t, cron_t, is_active in items:
|
||||
name = fn["name"]
|
||||
lines.append(SEP)
|
||||
lines.append(f" {_comment(fn, http_t, cron_t)}")
|
||||
lines.append(f" name: {name}")
|
||||
lines.append(f" runtime: {fn.get('runtime', '?')}")
|
||||
lines.append(f" phase: {fn.get('phase', '?')}")
|
||||
lines.append(f" active: {'да' if is_active else 'нет'}")
|
||||
|
||||
if http_t:
|
||||
url = f"{ext_url}/fn/{namespace}/{name}" if ext_url else http_t[0].get("url", "")
|
||||
lines.append(f" url: {url}")
|
||||
if cron_t:
|
||||
lines.append(f" cron: {cron_t[0].get('schedule', '?')}")
|
||||
if fn.get("created_at"):
|
||||
lines.append(f" created: {fn['created_at']}")
|
||||
if fn.get("last_built_at"):
|
||||
lines.append(f" built: {fn['last_built_at']}")
|
||||
if fn.get("message"):
|
||||
lines.append(f" message: {fn['message']}")
|
||||
|
||||
lines.append(SEP)
|
||||
lines.append(f" namespace: {namespace} | total: {len(items)}")
|
||||
lines.append(SEP)
|
||||
|
||||
# Возвращаем str — python runtime отдаст text/plain напрямую
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
return {
|
||||
"namespace": namespace,
|
||||
"count": len(result),
|
||||
"functions": result,
|
||||
}
|
||||
|
||||
|
||||
@@ -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.2", nil
|
||||
return "naeel/sless-runtime-python3.11:v0.1.3", nil
|
||||
case "nodejs20":
|
||||
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
||||
case "go1.23":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Изменено: 2026-03-11
|
||||
# Изменено: 2026-03-18 (string return → text/plain без json.dumps)
|
||||
# HTTP-обёртка для serverless функций на Python 3.11.
|
||||
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
|
||||
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
|
||||
@@ -110,9 +110,16 @@ class FunctionHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
|
||||
def _respond(self, status, data):
|
||||
# Если функция вернула строку — отдаём как text/plain без json.dumps.
|
||||
# Позволяет функциям возвращать человекочитаемый текст напрямую (curl без парсинга).
|
||||
if isinstance(data, str):
|
||||
body = data.encode("utf-8")
|
||||
ctype = "text/plain; charset=utf-8"
|
||||
else:
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
ctype = "application/json"
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# 2026-03-18
|
||||
# funcs-service.yaml — глобальный сервис листинга функций для всех пользователей.
|
||||
# Развёртывается ОДИН РАЗ в namespace sless рядом с оператором.
|
||||
# Доступен по: https://sless.kube5s.ru/funcs (с Bearer токеном пользователя)
|
||||
#
|
||||
# Обновить образ и применить:
|
||||
# docker push naeel/sless-funcs-service:v0.1.0
|
||||
# kubectl apply -f deployments/k8s/funcs-service.yaml
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: sless-funcs-service
|
||||
namespace: sless
|
||||
labels:
|
||||
app: sless-funcs-service
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: sless-funcs-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: sless-funcs-service
|
||||
spec:
|
||||
containers:
|
||||
- name: funcs
|
||||
image: naeel/sless-funcs-service:v0.1.3
|
||||
ports:
|
||||
- containerPort: 8090
|
||||
env:
|
||||
- name: SLESS_OPERATOR_URL
|
||||
value: "http://sless-operator.sless.svc.cluster.local:9090"
|
||||
- name: SLESS_EXTERNAL_URL
|
||||
value: "https://sless.kube5s.ru"
|
||||
# Системные функции, скрытые из листинга
|
||||
- name: SLESS_EXCLUDE
|
||||
value: "event-writer,event-monitor,event-cleaner"
|
||||
# Токен сервиса задаётся через kubectl set env или Secret — не хранится в репо
|
||||
# kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN="$(cat secrets/test.token)"
|
||||
- name: PORT
|
||||
value: "8090"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8090
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8090
|
||||
initialDelaySeconds: 3
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 100m
|
||||
memory: 64Mi
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: sless-funcs-service
|
||||
namespace: sless
|
||||
spec:
|
||||
selector:
|
||||
app: sless-funcs-service
|
||||
ports:
|
||||
- port: 8090
|
||||
targetPort: 8090
|
||||
|
||||
---
|
||||
# Отдельный Ingress для /funcs — nginx выбирает более специфичный путь перед /
|
||||
# Без rewrite: сервис сам обрабатывает /funcs path
|
||||
# TLS-сертификат sless-operator-tls уже управляется cert-manager через ingress оператора;
|
||||
# здесь только ссылаемся на существующий секрет без аннотации cert-manager.io/cluster-issuer.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: sless-funcs-ingress
|
||||
namespace: sless
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: sless.kube5s.ru
|
||||
http:
|
||||
paths:
|
||||
- path: /funcs
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: sless-funcs-service
|
||||
port:
|
||||
number: 8090
|
||||
tls:
|
||||
- hosts:
|
||||
- sless.kube5s.ru
|
||||
secretName: sless-operator-tls
|
||||
@@ -1,4 +1,4 @@
|
||||
// 2026-03-11
|
||||
// 2026-03-17 12:20
|
||||
// client.go — HTTP-клиент для REST API sless оператора.
|
||||
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
|
||||
// Все методы принимают ctx для правильной работы с таймаутами terraform.
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
@@ -44,6 +45,9 @@ type Client struct {
|
||||
Namespace string
|
||||
}
|
||||
|
||||
// ErrJobAlreadyExists возвращается при попытке создать FunctionJob с уже существующим именем.
|
||||
var ErrJobAlreadyExists = errors.New("job already exists")
|
||||
|
||||
// New создаёт клиент.
|
||||
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
|
||||
// - token — Bearer JWT-токен облака
|
||||
@@ -456,6 +460,9 @@ func (c *Client) CreateJob(ctx context.Context, ns string, req JobRequest) (*Job
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
return nil, fmt.Errorf("%w: %s", ErrJobAlreadyExists, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil, fmt.Errorf("create job: status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
var j JobResponse
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 2026-03-08
|
||||
// 2026-03-17 12:20
|
||||
// job_resource.go — Terraform ресурс sless_job.
|
||||
//
|
||||
// Lifecycle:
|
||||
@@ -17,6 +17,7 @@ package resources
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -163,16 +164,29 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
||||
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
|
||||
// Пользователь может поменять run_id > 0 позже чтобы запустить.
|
||||
runID := plan.RunID.ValueInt64()
|
||||
_, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
||||
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
||||
Name: plan.Name.ValueString(),
|
||||
FunctionRef: plan.Function.ValueString(),
|
||||
EventJSON: eventJSON,
|
||||
RunID: runID,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrJobAlreadyExists) {
|
||||
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
|
||||
if getErr != nil {
|
||||
resp.Diagnostics.AddError("create job", fmt.Sprintf("job already exists and get existing failed: %s", getErr.Error()))
|
||||
return
|
||||
}
|
||||
if existingJob == nil {
|
||||
resp.Diagnostics.AddError("create job", "job already exists but cannot be read right after conflict")
|
||||
return
|
||||
}
|
||||
createdJob = existingJob
|
||||
} else {
|
||||
resp.Diagnostics.AddError("create job", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Если RunID=0 — не ждём завершения, пишем state сразу
|
||||
if runID == 0 {
|
||||
@@ -187,10 +201,10 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
||||
EventJSON: plan.EventJSON,
|
||||
RunID: types.Int64Value(0),
|
||||
WaitTimeoutSec: waitTimeoutSec,
|
||||
Phase: types.StringValue("Skipped"),
|
||||
StartTime: types.StringValue(""),
|
||||
CompletionTime: types.StringValue(""),
|
||||
Message: types.StringValue("run_id=0: set run_id>0 to execute"),
|
||||
Phase: types.StringValue(createdJob.Phase),
|
||||
StartTime: types.StringValue(createdJob.StartTime),
|
||||
CompletionTime: types.StringValue(createdJob.CompletionTime),
|
||||
Message: types.StringValue(createdJob.Message),
|
||||
})...)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user