From a04dfb2d0c159c27fbafc33e72df99e38ebc1bdf Mon Sep 17 00:00:00 2001 From: Naeel Date: Wed, 18 Mar 2026 17:41:44 +0300 Subject: [PATCH] =?UTF-8?q?fix+docs:=20FunctionJob=20label=20bugfix,=20job?= =?UTF-8?q?=20ErrAlreadyExists,=20python=20str=E2=86=92text/plain,=20opera?= =?UTF-8?q?tor.yaml=20v0.1.33,=20progress.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- controllers/functionjob_controller.go | 45 ++++- deployments/k8s/operator.yaml | 4 +- doc/progress.md | 66 +++++++- .../POSTGRES/code/funcs-list/funcs_list.py | 155 +++++++----------- internal/builder/context.go | 2 +- runtimes/python3.11/server.py | 13 +- services/funcs/funcs-service.yaml | 107 ++++++++++++ terraform/provider/internal/client/client.go | 9 +- .../internal/resources/job_resource.go | 30 +++- 9 files changed, 308 insertions(+), 123 deletions(-) create mode 100644 services/funcs/funcs-service.yaml diff --git a/controllers/functionjob_controller.go b/controllers/functionjob_controller.go index acde042..af94795 100644 --- a/controllers/functionjob_controller.go +++ b/controllers/functionjob_controller.go @@ -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=" (наш лейбл, +// выставляется на 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 { diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index a48c157..51959bb 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -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: diff --git a/doc/progress.md b/doc/progress.md index c04ed0e..13f60e1 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -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="` вместо `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 | ✅ | Задеплоен | --- diff --git a/examples/POSTGRES/code/funcs-list/funcs_list.py b/examples/POSTGRES/code/funcs-list/funcs_list.py index bc17fdc..68157d2 100644 --- a/examples/POSTGRES/code/funcs-list/funcs_list.py +++ b/examples/POSTGRES/code/funcs-list/funcs_list.py @@ -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, - } diff --git a/internal/builder/context.go b/internal/builder/context.go index 5ce4387..d42cd05 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.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": diff --git a/runtimes/python3.11/server.py b/runtimes/python3.11/server.py index 3bd9969..4ba74e1 100644 --- a/runtimes/python3.11/server.py +++ b/runtimes/python3.11/server.py @@ -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): - body = json.dumps(data).encode("utf-8") + # Если функция вернула строку — отдаём как 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) diff --git a/services/funcs/funcs-service.yaml b/services/funcs/funcs-service.yaml new file mode 100644 index 0000000..47a5d84 --- /dev/null +++ b/services/funcs/funcs-service.yaml @@ -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 diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 5a864da..775aac3 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -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 diff --git a/terraform/provider/internal/resources/job_resource.go b/terraform/provider/internal/resources/job_resource.go index 33c54e9..ad31fb6 100644 --- a/terraform/provider/internal/resources/job_resource.go +++ b/terraform/provider/internal/resources/job_resource.go @@ -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,15 +164,28 @@ 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 { - resp.Diagnostics.AddError("create job", err.Error()) - return + 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 сразу @@ -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 }