Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
654bcb0589 | ||
|
|
8ce81d3ab1 |
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-03-17 20:00 (bugfix: job-name label удалён в k8s 1.27+, split-brain cached client)
|
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
|
||||||
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
// FunctionJobReconciler — контроллер одноразовых запусков функций.
|
||||||
// При создании FunctionJob:
|
// При создании FunctionJob:
|
||||||
// 1. Ждёт пока Function станет Ready
|
// 1. Ждёт пока Function станет Ready
|
||||||
@@ -134,13 +134,6 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
|
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
|
||||||
TTLSecondsAfterFinished: &ttl,
|
TTLSecondsAfterFinished: &ttl,
|
||||||
Template: corev1.PodTemplateSpec{
|
Template: corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
|
||||||
Labels: map[string]string{
|
|
||||||
"managed-by": "sless",
|
|
||||||
"functionjob": fj.Name,
|
|
||||||
"function": fn.Name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
RestartPolicy: corev1.RestartPolicyNever,
|
RestartPolicy: corev1.RestartPolicyNever,
|
||||||
// Используем тот же образ что и Deployment функции
|
// Используем тот же образ что и Deployment функции
|
||||||
@@ -201,19 +194,12 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
|||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
|
||||||
fj.Status.CompletionTime = &now
|
fj.Status.CompletionTime = &now
|
||||||
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
|
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
|
||||||
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, "functionjob="+fj.Name)
|
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
|
||||||
} else if job.Status.Failed > 0 {
|
} else if job.Status.Failed > 0 {
|
||||||
now := metav1.Now()
|
now := metav1.Now()
|
||||||
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
|
||||||
fj.Status.CompletionTime = &now
|
fj.Status.CompletionTime = &now
|
||||||
// Захватываем логи по нашему лейблу functionjob= (работает во всех версиях k8s).
|
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
|
||||||
// Устаревший 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 {
|
} else {
|
||||||
// Job ещё выполняется — перечитаем через 5 секунд
|
// Job ещё выполняется — перечитаем через 5 секунд
|
||||||
if err := r.Status().Update(ctx, fj); err != nil {
|
if err := r.Status().Update(ctx, fj); err != nil {
|
||||||
@@ -227,17 +213,6 @@ func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1al
|
|||||||
return ctrl.Result{}, nil
|
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-сервера.
|
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
|
||||||
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
|
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
|
||||||
func runtimeRunnerCommand(runtime string) []string {
|
func runtimeRunnerCommand(runtime string) []string {
|
||||||
@@ -305,22 +280,16 @@ func goJobModeEnv(runtime string) []corev1.EnvVar {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getJobPodOutput находит под по labelSelector и возвращает его stdout+stderr (trimmed).
|
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
|
||||||
// runner.py/runner.js печатают json.dumps(result) в stdout — return value функции.
|
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
|
||||||
// Исключения/трейсбэки Python/Node пишут в stderr — поэтому собираем оба потока.
|
|
||||||
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
|
||||||
// labelSelector передаётся снаружи — вызывающий код использует "functionjob=<name>" (наш лейбл,
|
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
|
||||||
// выставляется на 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{
|
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: labelSelector,
|
LabelSelector: "job-name=" + jobName,
|
||||||
})
|
})
|
||||||
if err != nil || len(pods.Items) == 0 {
|
if err != nil || len(pods.Items) == 0 {
|
||||||
return "completed successfully"
|
return "completed successfully"
|
||||||
}
|
}
|
||||||
// Stdout: true, Stderr: true — собираем оба потока.
|
|
||||||
// Python исключения идут в stderr, runner.py пишет результат в stdout.
|
|
||||||
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
||||||
stream, err := req.Stream(ctx)
|
stream, err := req.Stream(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Состав:
|
# Состав:
|
||||||
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
|
||||||
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
|
||||||
# - Deployment: оператор naeel/sless-operator:v0.1.33 в namespace sless
|
# - Deployment: оператор naeel/sless-operator:v0.1.23 в namespace sless
|
||||||
# - Service: ClusterIP :9090 (REST API)
|
# - Service: ClusterIP :9090 (REST API)
|
||||||
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
|
# - Ingress: sless.kube5s.ru → :9090 (внешний доступ с TLS)
|
||||||
#
|
#
|
||||||
@@ -72,7 +72,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: operator
|
- name: operator
|
||||||
# При обновлении версии оператора — менять тег здесь (не latest!)
|
# При обновлении версии оператора — менять тег здесь (не latest!)
|
||||||
image: naeel/sless-operator:v0.1.33
|
image: naeel/sless-operator:v0.1.32
|
||||||
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+8
-38
@@ -1,7 +1,5 @@
|
|||||||
# API Design
|
# API Design
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18
|
|
||||||
|
|
||||||
## Базовый URL
|
## Базовый URL
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -9,38 +7,15 @@ http://<operator-host>:9090/v1
|
|||||||
```
|
```
|
||||||
|
|
||||||
Локально: `http://localhost:9090/v1`
|
Локально: `http://localhost:9090/v1`
|
||||||
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
|
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1`
|
||||||
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
|
|
||||||
|
|
||||||
**Реализованные эндпоинты:**
|
**Реализовано** (текущий namespace-aware формат):
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /v1/namespaces/{ns}/functions
|
/v1/namespaces/{namespace}/functions[/{name}]
|
||||||
POST /v1/namespaces/{ns}/functions
|
/v1/namespaces/{namespace}/functions/{name}/upload
|
||||||
GET /v1/namespaces/{ns}/functions/{name}
|
/v1/namespaces/{namespace}/triggers[/{name}]
|
||||||
PUT /v1/namespaces/{ns}/functions/{name}
|
/v1/namespaces/{namespace}/functions/{name}/invocations
|
||||||
DELETE /v1/namespaces/{ns}/functions/{name}
|
|
||||||
POST /v1/namespaces/{ns}/functions/{name}/upload
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/source ← файлы кода из S3 tar.gz (JSON)
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/invocations
|
|
||||||
GET /v1/namespaces/{ns}/triggers
|
|
||||||
POST /v1/namespaces/{ns}/triggers
|
|
||||||
GET /v1/namespaces/{ns}/triggers/{name}
|
|
||||||
PATCH /v1/namespaces/{ns}/triggers/{name} ← {"enabled": bool}
|
|
||||||
DELETE /v1/namespaces/{ns}/triggers/{name}
|
|
||||||
POST /v1/namespaces/{ns}/jobs
|
|
||||||
GET /v1/namespaces/{ns}/jobs/{name}
|
|
||||||
DELETE /v1/namespaces/{ns}/jobs/{name}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Глобальный сервис funcs (не оператор):**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://sless.kube5s.ru/funcs/<namespace> ← plain text (курл) / HTML (браузер)
|
|
||||||
GET https://sless.kube5s.ru/funcs?token=<jwt> ← редирект по namespace
|
|
||||||
GET https://sless.kube5s.ru/funcs/<namespace>/source/<fn> ← прокси к GET /source
|
|
||||||
PATCH https://sless.kube5s.ru/funcs/<namespace>/triggers/<name> ← прокси к PATCH /triggers
|
|
||||||
GET https://sless.kube5s.ru/health ← liveness probe
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Аутентификация
|
## Аутентификация
|
||||||
@@ -49,11 +24,6 @@ GET https://sless.kube5s.ru/health ← liveness
|
|||||||
Authorization: Bearer <cloud-token>
|
Authorization: Bearer <cloud-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Токен — JWT от `auth-api`. Middleware в операторе:
|
|
||||||
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
|
|
||||||
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
|
|
||||||
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
|
|
||||||
|
|
||||||
## Ресурсы
|
## Ресурсы
|
||||||
|
|
||||||
### Functions
|
### Functions
|
||||||
@@ -117,8 +87,8 @@ field: code = <zip-file>
|
|||||||
## Поддерживаемые runtime (v1)
|
## Поддерживаемые runtime (v1)
|
||||||
|
|
||||||
- `python3.11` — реализован и протестирован
|
- `python3.11` — реализован и протестирован
|
||||||
- `nodejs20` — реализован и протестирован
|
- `go1.21` — планируется
|
||||||
- `go1.23` — реализован
|
- `nodejs20` — планируется
|
||||||
|
|
||||||
## Модель Function
|
## Модель Function
|
||||||
|
|
||||||
|
|||||||
@@ -1,368 +0,0 @@
|
|||||||
# Agent Handoff — 2026-03-18 (финальное состояние сессии)
|
|
||||||
|
|
||||||
Этот файл — полный срез для нового агента: что сделано, как устроено, как работать.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Идентификация проекта
|
|
||||||
|
|
||||||
- **Репозиторий:** `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
|
|
||||||
- **Локальная копия:** `/home/naeel/remote_dev/sless/`
|
|
||||||
- **Remote server:** `naeel@5.172.178.213` (workspace: `~/terra/sless/`)
|
|
||||||
- **SSH ключ:** `/home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519`
|
|
||||||
- **SSH команда:** `ssh -i <ключ> -o StrictHostKeyChecking=no naeel@5.172.178.213`
|
|
||||||
- **Активная ветка:** `feat/web-console` (последний коммит `a04dfb2`)
|
|
||||||
- **Git origin:** `https://gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless.git`
|
|
||||||
|
|
||||||
### Важно про git
|
|
||||||
Git **не работает локально** (зависает при записи объектов из-за NFS-подобного поведения volume).
|
|
||||||
Все `git add / commit / push` — **только через SSH на remote machine**.
|
|
||||||
`scp` для копирования файлов → `ssh` для git-операций.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Что такое проект
|
|
||||||
|
|
||||||
Managed Serverless Functions Service для облачного провайдера **nubes.ru**.
|
|
||||||
|
|
||||||
Пользователь пишет `main.tf` с ресурсами `sless_function`, `sless_trigger`, `sless_job`.
|
|
||||||
Terraform провайдер собирает zip → загружает в оператор → оператор запускает kaniko → Docker образ → Deployment в k8s.
|
|
||||||
|
|
||||||
**Внешнее API оператора:** `https://sless.kube5s.ru`
|
|
||||||
**Ingress IP:** `185.247.187.147`
|
|
||||||
**Namespace оператора в k8s:** `sless`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Стек и компоненты
|
|
||||||
|
|
||||||
| Компонент | Технология | Namespace / где |
|
|
||||||
|-----------|-----------|-----------------|
|
|
||||||
| Operator (API + Controllers) | Go (controller-runtime) | k8s namespace `sless` |
|
|
||||||
| funcs-service (web-консоль) | Go (net/http) | k8s namespace `sless` |
|
|
||||||
| PostgreSQL | PostgreSQL 16 | k8s namespace `sless` |
|
|
||||||
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
|
||||||
| Container Registry | DockerHub (`naeel/`) | внешний |
|
|
||||||
| Builder | kaniko Job | namespace пользователя |
|
|
||||||
| Function (HTTP trigger) | k8s Deployment + Service | namespace пользователя |
|
|
||||||
| Function (one-shot) | k8s Job | namespace пользователя |
|
|
||||||
| Function (cron) | k8s CronJob | namespace пользователя |
|
|
||||||
| Terraform Provider | Go (plugin-framework v6) | localhost/CI |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Текущие версии образов
|
|
||||||
|
|
||||||
| Образ | Версия | Что внутри |
|
|
||||||
|-------|--------|-----------|
|
|
||||||
| `naeel/sless-operator` | **v0.1.34** | REST API + k8s controllers; GET /source; proxy-готовый PATCH /triggers |
|
|
||||||
| `naeel/sless-funcs-service` | **v0.2.0** | HTML web-консоль + plain text (backward compat) |
|
|
||||||
| `naeel/sless-runtime-python3.11` | **v0.1.3** | str return → text/plain |
|
|
||||||
| `naeel/sless-runtime-nodejs20` | **v0.1.2** | без изменений |
|
|
||||||
| `naeel/sless-runtime-go1.23` | **v0.1.0** | без изменений |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Структура директорий (актуальная)
|
|
||||||
|
|
||||||
```
|
|
||||||
sless/
|
|
||||||
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
|
|
||||||
├── Dockerfile # сборка оператора
|
|
||||||
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
|
|
||||||
├── api/v1alpha1/ # CRD типы: Function, FunctionJob, Trigger
|
|
||||||
│ ├── function_types.go
|
|
||||||
│ ├── job_types.go
|
|
||||||
│ └── trigger_types.go
|
|
||||||
├── controllers/ # k8s reconcilers
|
|
||||||
│ ├── function_controller.go # Function CRD → kaniko → Deployment/Service
|
|
||||||
│ ├── functionjob_controller.go # FunctionJob CRD → k8s Job → собирает stdout/stderr
|
|
||||||
│ └── trigger_controller.go # Trigger CRD → Deployment scale / CronJob
|
|
||||||
├── internal/
|
|
||||||
│ ├── api/
|
|
||||||
│ │ ├── router.go # gorilla/mux: все REST маршруты (актуальный)
|
|
||||||
│ │ ├── middleware/ # Auth (JWT→namespace), Logging
|
|
||||||
│ │ └── handler/
|
|
||||||
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
|
|
||||||
│ │ ├── functions.go # CRUD Functions
|
|
||||||
│ │ ├── triggers.go # CRUD Triggers + UpdateTrigger (PATCH enabled)
|
|
||||||
│ │ ├── upload.go # POST /upload — zip → tar.gz → S3 → Function CRD patch
|
|
||||||
│ │ ├── source.go # GET /source — tar.gz из S3 → JSON файлы (НОВЫЙ)
|
|
||||||
│ │ ├── jobs.go # CRUD FunctionJobs
|
|
||||||
│ │ ├── invocations.go # logs из Postgres
|
|
||||||
│ │ ├── invoke.go # прокси вызова HTTP функций
|
|
||||||
│ │ └── namespace.go # EnsureNamespace
|
|
||||||
│ ├── builder/
|
|
||||||
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
|
|
||||||
│ ├── storage/
|
|
||||||
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
|
|
||||||
│ │ └── postgres/ # хранение invocation logs
|
|
||||||
│ └── config/ # env vars конфиг
|
|
||||||
├── services/
|
|
||||||
│ └── funcs/
|
|
||||||
│ ├── main.go # web-консоль сервис (v0.2.0)
|
|
||||||
│ ├── index.html # HTML шаблон (embed)
|
|
||||||
│ ├── Dockerfile # multi-stage Go → alpine
|
|
||||||
│ └── funcs-service.yaml # (дубль, не деплоится отсюда)
|
|
||||||
├── deployments/k8s/
|
|
||||||
│ ├── operator.yaml # ConfigMap + Secret + Deployment + Service + Ingress оператора
|
|
||||||
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
|
|
||||||
│ ├── postgres.yaml # PostgreSQL
|
|
||||||
│ └── rbac.yaml # ClusterRole для оператора
|
|
||||||
├── terraform/provider/ # terraform-provider-sless
|
|
||||||
│ ├── main.go
|
|
||||||
│ └── internal/
|
|
||||||
│ ├── client/client.go # HTTP клиент к оператору
|
|
||||||
│ └── resources/
|
|
||||||
│ ├── function_resource.go # sless_function: source_dir→zip, code_hash, ModifyPlan
|
|
||||||
│ ├── trigger_resource.go # sless_trigger
|
|
||||||
│ └── job_resource.go # sless_job + ErrJobAlreadyExists handling
|
|
||||||
├── runtimes/
|
|
||||||
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain)
|
|
||||||
│ ├── nodejs20/ # HTTP wrapper
|
|
||||||
│ └── go1.23/ # multi-stage builder образ
|
|
||||||
├── examples/
|
|
||||||
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
|
|
||||||
│ ├── hello-go/
|
|
||||||
│ ├── hello-node/
|
|
||||||
│ └── ...
|
|
||||||
├── migrations/001_initial.sql # PostgreSQL схема
|
|
||||||
└── doc/ # ← ты здесь
|
|
||||||
├── architecture/
|
|
||||||
│ └── agent-handoff-2026-03-18.md ← ЭТОТ ФАЙЛ
|
|
||||||
├── api/design.md
|
|
||||||
├── decisions/log.md
|
|
||||||
├── errors/log.md
|
|
||||||
└── progress.md
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. REST API оператора — полный список маршрутов
|
|
||||||
|
|
||||||
Все `/v1/` защищены JWT (middleware.Auth проверяет Bearer токен + namespace).
|
|
||||||
`/fn/` — публичный прокси для HTTP-триггеров (без auth).
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/namespaces/{ns}/ensure # создать namespace (идемпотентно)
|
|
||||||
|
|
||||||
GET /v1/namespaces/{ns}/functions # список функций
|
|
||||||
POST /v1/namespaces/{ns}/functions # создать функцию
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name} # получить функцию
|
|
||||||
PUT /v1/namespaces/{ns}/functions/{name} # обновить функцию
|
|
||||||
DELETE /v1/namespaces/{ns}/functions/{name} # удалить функцию
|
|
||||||
POST /v1/namespaces/{ns}/functions/{name}/upload # загрузить zip → S3 → kaniko
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/source # НОВЫЙ: файлы кода из S3 (JSON)
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/invocations # логи вызовов
|
|
||||||
|
|
||||||
GET /v1/namespaces/{ns}/triggers # список триггеров
|
|
||||||
POST /v1/namespaces/{ns}/triggers # создать триггер
|
|
||||||
GET /v1/namespaces/{ns}/triggers/{name} # получить триггер
|
|
||||||
PATCH /v1/namespaces/{ns}/triggers/{name} # enable/disable: {"enabled": bool}
|
|
||||||
DELETE /v1/namespaces/{ns}/triggers/{name} # удалить триггер
|
|
||||||
|
|
||||||
POST /v1/namespaces/{ns}/jobs # создать FunctionJob
|
|
||||||
GET /v1/namespaces/{ns}/jobs/{name} # получить FunctionJob
|
|
||||||
DELETE /v1/namespaces/{ns}/jobs/{name} # удалить FunctionJob
|
|
||||||
|
|
||||||
GET/POST/... /fn/{namespace}/{name}[/...] # вызов HTTP функции (без auth)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. funcs-service — маршруты
|
|
||||||
|
|
||||||
Сервис `sless-funcs-service`, namespace `sless`, порт 8090.
|
|
||||||
Ingress: `sless.kube5s.ru/funcs` → `sless-funcs-service:8090`.
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /health # liveness/readiness probe (без auth)
|
|
||||||
GET /funcs # usage hint (нет токена → 401 с подсказкой)
|
|
||||||
GET /funcs?token=<jwt> # листинг через JWT
|
|
||||||
GET /funcs/<namespace> # листинг по namespace (браузер→HTML, curl→plain text)
|
|
||||||
GET /funcs/<namespace>/source/<fn> # прокси → оператор GET /source (serviceToken)
|
|
||||||
PATCH /funcs/<namespace>/triggers/<name> # прокси → оператор PATCH /triggers (serviceToken)
|
|
||||||
```
|
|
||||||
|
|
||||||
Логика переключения HTML/plain text: `strings.Contains(Accept header, "text/html")`.
|
|
||||||
Браузер всегда шлёт `text/html` в Accept → HTML консоль.
|
|
||||||
`curl` без `-H "Accept: text/html"` → plain text (совместимость с v0.1.x).
|
|
||||||
|
|
||||||
**Env vars funcs-service:**
|
|
||||||
```
|
|
||||||
SLESS_OPERATOR_URL = http://sless-operator.sless.svc.cluster.local:9090
|
|
||||||
SLESS_EXTERNAL_URL = https://sless.kube5s.ru
|
|
||||||
SLESS_EXCLUDE = (список функций скрытых из листинга, через запятую)
|
|
||||||
SLESS_SERVICE_TOKEN = <JWT> (задаётся через kubectl set env, НЕ в git)
|
|
||||||
PORT = 8090
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Namespace пользователя
|
|
||||||
|
|
||||||
```
|
|
||||||
JWT.sub (UUID) → SHA256(sub)[:8] → hex → "sless-" + 16 hex символов
|
|
||||||
```
|
|
||||||
|
|
||||||
Пример: sub `019cc268-6c6a-781e-8613-4bed4ec7cd20` → namespace `sless-ffd1f598c169b0ae`
|
|
||||||
|
|
||||||
Эта логика **одинакова** в трёх местах:
|
|
||||||
- `internal/api/middleware/auth.go` (оператор)
|
|
||||||
- `services/funcs/main.go` (funcs-service)
|
|
||||||
- `terraform/provider/internal/client/client.go` (провайдер)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. S3 хранение кода — ключи
|
|
||||||
|
|
||||||
При `POST /upload` создаются **два объекта:**
|
|
||||||
|
|
||||||
```
|
|
||||||
functions/{ns}/{name}/{timestamp}.zip ← исходный код (zip от пользователя)
|
|
||||||
contexts/{ns}/{name}/{timestamp}.tar.gz ← build context для kaniko (zip + Dockerfile)
|
|
||||||
```
|
|
||||||
|
|
||||||
`Function.Spec.S3Key` хранит путь к `contexts/...`.
|
|
||||||
`GET /source` читает `Function.Spec.S3Key`, скачивает tar.gz, извлекает файлы без Dockerfile.
|
|
||||||
|
|
||||||
**Важно:** zip исходника (`functions/...`) отдельно не хранится в CRD.
|
|
||||||
Код источника берётся из tar.gz контекста — там те же файлы пользователя.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Terraform провайдер — sless_function
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
resource "sless_function" "my_func" {
|
|
||||||
name = "my-func"
|
|
||||||
runtime = "python3.11" # python3.11 | nodejs20 | go1.23
|
|
||||||
entrypoint = "handler.handle"
|
|
||||||
|
|
||||||
source_dir = "${path.module}/code/my-func" # директория → провайдер делает zip сам
|
|
||||||
# ИЛИ:
|
|
||||||
# code_path = "./handler.zip" # готовый zip
|
|
||||||
# code_hash = filesha256("./handler.zip") # для детекции изменений
|
|
||||||
|
|
||||||
memory_mb = 128
|
|
||||||
timeout_sec = 30
|
|
||||||
env_vars = { KEY = "value" }
|
|
||||||
build_timeout_sec = 300 # ожидание kaniko (дефолт 300 сек)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
При изменении файлов в `source_dir`:
|
|
||||||
- `terraform plan` → пересчитывает `code_hash` (ModifyPlan), показывает diff
|
|
||||||
- `terraform apply` → загружает новый zip, ждёт сборки → `phase = Ready`
|
|
||||||
- Новый код виден в браузере сразу после apply
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Где что запущено (kubectl)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Проверить поды оператора
|
|
||||||
kubectl get pods -n sless
|
|
||||||
|
|
||||||
# Проверить версию образа оператора
|
|
||||||
kubectl get deployment sless-operator -n sless -o jsonpath='{.spec.template.spec.containers[0].image}'
|
|
||||||
|
|
||||||
# Посмотреть логи оператора
|
|
||||||
kubectl logs -n sless -l app=sless-operator --tail=50
|
|
||||||
|
|
||||||
# Посмотреть логи funcs-service
|
|
||||||
kubectl logs -n sless -l app=sless-funcs-service --tail=50
|
|
||||||
|
|
||||||
# Обновить образ оператора
|
|
||||||
kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless
|
|
||||||
kubectl rollout status deployment/sless-operator -n sless --timeout=90s
|
|
||||||
|
|
||||||
# Обновить образ funcs-service
|
|
||||||
kubectl set image deployment/sless-funcs-service funcs=naeel/sless-funcs-service:vX.X.X -n sless
|
|
||||||
|
|
||||||
# Посмотреть функции пользователя
|
|
||||||
kubectl get functions -n sless-ffd1f598c169b0ae
|
|
||||||
|
|
||||||
# Посмотреть триггеры
|
|
||||||
kubectl get triggers -n sless-ffd1f598c169b0ae
|
|
||||||
|
|
||||||
# Обновить SLESS_SERVICE_TOKEN (не хранится в git!)
|
|
||||||
kubectl set env deployment/sless-funcs-service -n sless SLESS_SERVICE_TOKEN=<jwt>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Тестовые данные
|
|
||||||
|
|
||||||
- **Тестовый токен:** `/home/naeel/remote_dev/sless/secrets/test.token`
|
|
||||||
- **Namespace тест-пользователя:** `sless-ffd1f598c169b0ae`
|
|
||||||
- **Рабочие функции:**
|
|
||||||
- `pg-info` (nodejs20) — читает версию PostgreSQL и счётчик строк
|
|
||||||
- `pg-table-reader` (python3.11) — читает строки из таблицы
|
|
||||||
- `pg-create-table-runner` (python3.11) — job: создаёт таблицу
|
|
||||||
- **Быстрый тест:**
|
|
||||||
```bash
|
|
||||||
# plain text список (curl)
|
|
||||||
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
|
|
||||||
|
|
||||||
# HTML консоль (браузер или curl с Accept)
|
|
||||||
curl -H "Accept: text/html" https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae
|
|
||||||
|
|
||||||
# исходный код функции
|
|
||||||
curl https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae/source/pg-info
|
|
||||||
|
|
||||||
# вызов функции
|
|
||||||
curl https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Деплой нового образа — стандартный workflow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SSH="ssh -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213"
|
|
||||||
SCP="scp -i /home/naeel/remote_dev/sless/secrets/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no"
|
|
||||||
|
|
||||||
# 1. Скопировать изменённые файлы оператора на remote
|
|
||||||
$SCP /home/naeel/remote_dev/sless/path/to/file.go naeel@5.172.178.213:~/terra/sless/path/to/file.go
|
|
||||||
|
|
||||||
# 2. Собрать образ на remote
|
|
||||||
$SSH "cd ~/terra/sless && docker build -t naeel/sless-operator:vX.X.X . 2>&1 | tail -5"
|
|
||||||
|
|
||||||
# 3. Запушить
|
|
||||||
$SSH "docker push naeel/sless-operator:vX.X.X 2>&1 | tail -3"
|
|
||||||
|
|
||||||
# 4. Задеплоить
|
|
||||||
$SSH "kubectl set image deployment/sless-operator operator=naeel/sless-operator:vX.X.X -n sless && kubectl rollout status deployment/sless-operator -n sless --timeout=90s"
|
|
||||||
|
|
||||||
# 5. Коммит (через SSH! не локально)
|
|
||||||
$SSH "cd ~/terra/sless && git add <файлы> && git commit -m 'msg' && git push origin feat/web-console"
|
|
||||||
```
|
|
||||||
|
|
||||||
Для funcs-service: путь `~/terra/sless/services/funcs/`, контейнер называется `funcs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Известные проблемы / особенности
|
|
||||||
|
|
||||||
| Проблема | Решение |
|
|
||||||
|---------|---------|
|
|
||||||
| `git` зависает локально | Только через SSH на remote machine |
|
|
||||||
| `SLESS_SERVICE_TOKEN` не в git | Задан через `kubectl set env`, при пересоздании пода — пропадёт! Нужно переставить вручную |
|
|
||||||
| `job-name=` label удалён в k8s 1.27+ | Используем свой label `functionjob=<name>` на PodTemplate (исправлено в v0.1.34) |
|
|
||||||
| S3 endpoint с SSL | `useSSL=false` для внутреннего s3, `useSSL=true` для облачного `s3.msk-1.ngcloud.ru` |
|
|
||||||
| `python3.11`: str return → text/plain | Начиная с `naeel/sless-runtime-python3.11:v0.1.3` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Следующие возможные задачи (не начаты)
|
|
||||||
|
|
||||||
| Задача | Сложность | Заметки |
|
|
||||||
|--------|-----------|---------|
|
|
||||||
| Слияние `feat/web-console` в `main` (или базовую ветку) | низкая | Ветка стабильная, все тесты проходят |
|
|
||||||
| Кнопка "Обновить код" в HTML консоли (upload из браузера) | средняя | Drag&drop zip или указать source_dir |
|
|
||||||
| Обратная синхронизация (скачать код из S3 в source_dir) | средняя | terraform data source или отдельная команда |
|
|
||||||
| History/versioning (несколько версий кода) | высокая | S3 уже хранит по timestamp — нужен UI |
|
|
||||||
| SLESS_SERVICE_TOKEN из k8s Secret | низкая | Сейчас задаётся через kubectl set env — надо в YAML (sealed secret) |
|
|
||||||
| Логи функции в HTML консоли | средняя | GET /invocations уже есть в операторе |
|
|
||||||
| Публикация terraform провайдера | средняя | Terraform Registry или Gitea Releases |
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Архитектура системы
|
# Архитектура системы
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 (v0.1.34 + funcs-service v0.2.0)
|
Последнее обновление: 2026-03-11 (v0.1.22)
|
||||||
|
|
||||||
## Общее описание
|
## Общее описание
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ Managed Serverless Functions Service для облачного провайде
|
|||||||
| Компонент | Технология | Где запущен |
|
| Компонент | Технология | Где запущен |
|
||||||
|-----------|-----------|-------------|
|
|-----------|-----------|-------------|
|
||||||
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
||||||
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
|
|
||||||
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
||||||
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
||||||
| Container Registry | DockerHub (`naeel/`) | внешний |
|
| Container Registry | DockerHub (`naeel/`) | внешний |
|
||||||
@@ -26,44 +25,6 @@ Managed Serverless Functions Service для облачного провайде
|
|||||||
|
|
||||||
> Redis и RabbitMQ — отложены до v2.
|
> Redis и RabbitMQ — отложены до v2.
|
||||||
|
|
||||||
## Компонент: funcs-service
|
|
||||||
|
|
||||||
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
|
|
||||||
|
|
||||||
```
|
|
||||||
User Browser / curl
|
|
||||||
└─► https://sless.kube5s.ru/funcs/<namespace>
|
|
||||||
└─► nginx Ingress (sless-funcs-ingress)
|
|
||||||
└─► sless-funcs-service:8090 (namespace sless)
|
|
||||||
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `services/funcs/main.go` — логика
|
|
||||||
- `services/funcs/Dockerfile` — multi-stage Go → alpine
|
|
||||||
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
|
|
||||||
|
|
||||||
**Env vars сервиса:**
|
|
||||||
| Переменная | Значение |
|
|
||||||
|-----------|---------|
|
|
||||||
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
|
|
||||||
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
|
|
||||||
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
|
|
||||||
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
|
|
||||||
|
|
||||||
## Хранение кода функций в S3
|
|
||||||
|
|
||||||
```
|
|
||||||
Terraform source_dir (локально)
|
|
||||||
└─► zip → POST /upload → builder.PrepareContext()
|
|
||||||
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
|
|
||||||
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
|
|
||||||
└─► Function CRD: spec.s3Key = "contexts/..."
|
|
||||||
└─► контроллер → kaniko Job → Docker image
|
|
||||||
```
|
|
||||||
|
|
||||||
Для web-консоли: `GET /source` читает `contexts/{ns}/{name}/{ts}.tar.gz` (из `Function.Spec.S3Key`), распаковывает tar.gz, фильтрует Dockerfile, возвращает пользовательские файлы.
|
|
||||||
|
|
||||||
## Изоляция пользователей — Namespace per user
|
## Изоляция пользователей — Namespace per user
|
||||||
|
|
||||||
Каждый пользователь облака получает **отдельный k8s namespace**.
|
Каждый пользователь облака получает **отдельный k8s namespace**.
|
||||||
|
|||||||
@@ -1,106 +1,49 @@
|
|||||||
# Структура проекта
|
# Структура проекта
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 (v0.1.34)
|
|
||||||
|
|
||||||
## Репозиторий
|
## Репозиторий
|
||||||
|
|
||||||
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
|
`gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless`
|
||||||
|
|
||||||
## Директории (актуальное состояние)
|
## Директории
|
||||||
|
|
||||||
```
|
```
|
||||||
sless/
|
sless/
|
||||||
├── main.go # точка входа оператора (controller-runtime + HTTP сервер)
|
├── cmd/
|
||||||
├── Dockerfile # multi-stage сборка оператора
|
│ └── api/
|
||||||
├── go.mod # module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
|
│ └── main.go # точка входа API сервера
|
||||||
├── Makefile
|
|
||||||
├── api/
|
|
||||||
│ └── v1alpha1/ # CRD типы для controller-gen
|
|
||||||
│ ├── function_types.go # Function CRD: spec, status
|
|
||||||
│ ├── job_types.go # FunctionJob CRD
|
|
||||||
│ ├── trigger_types.go # Trigger CRD
|
|
||||||
│ └── zz_generated.deepcopy.go # автогенерация DeepCopy
|
|
||||||
├── controllers/
|
|
||||||
│ ├── function_controller.go # Function → kaniko Job → Deployment/Service
|
|
||||||
│ ├── functionjob_controller.go # FunctionJob → k8s Job → stdout/stderr → status
|
|
||||||
│ └── trigger_controller.go # Trigger → Deployment scale / CronJob
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── api/
|
│ ├── api/
|
||||||
│ │ ├── router.go # gorilla/mux: регистрация всех маршрутов
|
│ │ ├── handler/ # HTTP хендлеры (functions, versions, triggers)
|
||||||
│ │ ├── middleware/
|
│ │ ├── middleware/ # auth, logging, rate limit
|
||||||
│ │ │ ├── auth.go # JWT Bearer → namespace (SHA256)
|
│ │ └── router.go # регистрация маршрутов
|
||||||
│ │ │ └── logging.go # access log
|
│ ├── model/ # доменные модели: Function, Version, Trigger, Invocation
|
||||||
│ │ └── handler/
|
|
||||||
│ │ ├── handler.go # Handler struct (K8s, S3, PG, Log)
|
|
||||||
│ │ ├── functions.go # GET/POST/PUT/DELETE функций
|
|
||||||
│ │ ├── triggers.go # GET/POST/PATCH/DELETE триггеров
|
|
||||||
│ │ ├── upload.go # POST /upload: zip → tar.gz → S3 → CRD patch
|
|
||||||
│ │ ├── source.go # GET /source: S3 tar.gz → JSON файлы (НОВЫЙ v0.1.34)
|
|
||||||
│ │ ├── jobs.go # GET/POST/DELETE FunctionJob
|
|
||||||
│ │ ├── invocations.go # GET /invocations: логи из Postgres
|
|
||||||
│ │ ├── invoke.go # прокси HTTP вызовов функций
|
|
||||||
│ │ └── namespace.go # POST /ensure: создать k8s namespace
|
|
||||||
│ ├── builder/
|
|
||||||
│ │ └── context.go # zip + runtime → tar.gz + Dockerfile для kaniko
|
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ │ ├── s3/client.go # minio-go: Upload, UploadContext, Download, Delete
|
│ │ ├── postgres/ # CRUD функций, версий, логов вызовов
|
||||||
│ │ └── postgres/ # хранение invocation logs
|
│ │ └── s3/ # загрузка/скачивание zip архивов кода
|
||||||
│ └── config/ # конфиг из env переменных
|
│ ├── builder/ # сборка Docker образа из кода пользователя
|
||||||
├── services/
|
│ ├── runner/ # запуск функций в k8s (Jobs / Deployments)
|
||||||
│ └── funcs/ # отдельный HTTP сервис (web-консоль)
|
│ └── config/ # конфиг из env переменных
|
||||||
│ ├── main.go # v0.2.0: HTML+plain text+proxy к оператору
|
├── migrations/ # SQL миграции (numbered: 001_, 002_, ...)
|
||||||
│ ├── index.html # dark-themed HTML консоль (go:embed)
|
├── deployments/
|
||||||
│ ├── Dockerfile # multi-stage Go → alpine (копирует main.go + index.html)
|
│ └── k8s/ # манифесты: Deployment, Service, Ingress, RBAC
|
||||||
│ └── go.mod # отдельный Go модуль
|
├── api/
|
||||||
├── migrations/
|
│ └── openapi.yaml # OpenAPI 3.0 спецификация
|
||||||
│ └── 001_initial.sql # схема PostgreSQL (invocation logs)
|
├── doc/ # документация проекта (эта папка)
|
||||||
├── deployments/k8s/
|
└── docker-compose.yml # локальная разработка: postgres, redis, minio
|
||||||
│ ├── operator.yaml # ConfigMap + Deployment + Service + Ingress оператора
|
|
||||||
│ ├── funcs-service.yaml # Deployment + Service + Ingress funcs-service
|
|
||||||
│ ├── postgres.yaml # PostgreSQL
|
|
||||||
│ └── rbac.yaml # ClusterRole + ClusterRoleBinding для оператора
|
|
||||||
├── runtimes/
|
|
||||||
│ ├── python3.11/server.py # HTTP wrapper (str → text/plain начиная с v0.1.3)
|
|
||||||
│ ├── nodejs20/server.js # HTTP wrapper
|
|
||||||
│ └── go1.23/ # multi-stage builder образ
|
|
||||||
├── terraform/
|
|
||||||
│ └── provider/ # terraform-provider-sless (отдельный Go модуль)
|
|
||||||
│ ├── main.go
|
|
||||||
│ └── internal/
|
|
||||||
│ ├── client/client.go # REST клиент к оператору
|
|
||||||
│ ├── resources/
|
|
||||||
│ │ ├── function_resource.go # sless_function: source_dir, code_hash, ModifyPlan
|
|
||||||
│ │ ├── trigger_resource.go # sless_trigger
|
|
||||||
│ │ └── job_resource.go # sless_job + ErrJobAlreadyExists
|
|
||||||
│ └── provider/provider.go
|
|
||||||
├── examples/
|
|
||||||
│ ├── POSTGRES/ # pg функции: create-table, pg-info, pg-table-reader
|
|
||||||
│ ├── hello-go/
|
|
||||||
│ ├── hello-node/
|
|
||||||
│ └── simple-python/
|
|
||||||
├── config/ # kustomize конфиги (controller-gen артефакты)
|
|
||||||
│ ├── crd/ # CRD манифесты (автогенерация)
|
|
||||||
│ ├── rbac/
|
|
||||||
│ └── samples/
|
|
||||||
├── hack/
|
|
||||||
│ └── boilerplate.go.txt # шаблон заголовков файлов
|
|
||||||
└── doc/ # документация проекта
|
|
||||||
├── architecture/
|
|
||||||
│ ├── agent-handoff-2026-03-18.md # полный контекст для нового агента
|
|
||||||
│ ├── overview.md # архитектура системы
|
|
||||||
│ └── project-structure.md # этот файл
|
|
||||||
├── api/design.md # дизайн REST API
|
|
||||||
├── decisions/log.md # журнал архитектурных решений
|
|
||||||
├── errors/log.md # известные ошибки и решения
|
|
||||||
└── progress.md # трекер задач
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Go модули
|
## Go module
|
||||||
|
|
||||||
Два независимых Go модуля в одном репозитории:
|
```
|
||||||
|
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
|
||||||
|
```
|
||||||
|
|
||||||
| Модуль | Путь | Назначение |
|
## Порядок разработки
|
||||||
|--------|------|-----------|
|
|
||||||
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless` | `/` | Оператор + API |
|
1. `internal/config` + `internal/model` — базовые структуры данных
|
||||||
| `gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/services/funcs` | `services/funcs/` | Web-консоль сервис |
|
2. `migrations/` + `internal/storage/postgres` — схема БД и CRUD
|
||||||
| `terra.k8c.ru/naeel/sless` | `terraform/provider/` | Terraform провайдер |
|
3. `internal/api` — HTTP хендлеры, роутер, middleware
|
||||||
|
4. `internal/storage/s3` — загрузка кода функций
|
||||||
|
5. `internal/builder` — сборка Docker образов
|
||||||
|
6. `internal/runner` — запуск функций в k8s
|
||||||
|
7. `deployments/k8s` — манифесты для деплоя
|
||||||
|
|||||||
-159
@@ -1,159 +0,0 @@
|
|||||||
# API Design
|
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18
|
|
||||||
|
|
||||||
## Базовый URL
|
|
||||||
|
|
||||||
```
|
|
||||||
http://<operator-host>:9090/v1
|
|
||||||
```
|
|
||||||
|
|
||||||
Локально: `http://localhost:9090/v1`
|
|
||||||
В кластере: `http://sless-operator.sless.svc.cluster.local:9090/v1`
|
|
||||||
Публично: `https://sless.kube5s.ru/v1/...` (через Ingress)
|
|
||||||
|
|
||||||
**Реализованные эндпоинты:**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/namespaces/{ns}/functions
|
|
||||||
POST /v1/namespaces/{ns}/functions
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}
|
|
||||||
DELETE /v1/namespaces/{ns}/functions/{name}
|
|
||||||
POST /v1/namespaces/{ns}/functions/{name}/upload
|
|
||||||
GET /v1/namespaces/{ns}/triggers
|
|
||||||
POST /v1/namespaces/{ns}/triggers
|
|
||||||
GET /v1/namespaces/{ns}/triggers/{name}
|
|
||||||
DELETE /v1/namespaces/{ns}/triggers/{name}
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/invocations
|
|
||||||
```
|
|
||||||
|
|
||||||
**Запланированные эндпоинты (ветка feat/web-console):**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /v1/namespaces/{ns}/functions/{name}/source ← НОВЫЙ: код из S3 zip
|
|
||||||
PATCH /v1/namespaces/{ns}/triggers/{name} ← НОВЫЙ: enable/disable триггера
|
|
||||||
```
|
|
||||||
|
|
||||||
**Глобальный сервис funcs (не оператор):**
|
|
||||||
|
|
||||||
```
|
|
||||||
GET https://sless.kube5s.ru/funcs/<namespace> ← без токена, plain text / HTML
|
|
||||||
GET https://sless.kube5s.ru/funcs?token=<jwt> ← с токеном
|
|
||||||
GET https://sless.kube5s.ru/health ← liveness probe
|
|
||||||
```
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer <cloud-token>
|
|
||||||
```
|
|
||||||
|
|
||||||
Токен — JWT от `auth-api`. Middleware в операторе:
|
|
||||||
1. Извлекает `sub` из payload (без проверки подписи — доверяет Ingress)
|
|
||||||
2. Вычисляет namespace: `SHA256(sub)[:8]` hex → `sless-{16 hex символов}`
|
|
||||||
3. Проверяет что запрошенный `{namespace}` совпадает с вычисленным
|
|
||||||
|
|
||||||
## Ресурсы
|
|
||||||
|
|
||||||
### Functions
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| GET | /functions | Список функций |
|
|
||||||
| POST | /functions | Создать функцию |
|
|
||||||
| GET | /functions/{id} | Получить функцию |
|
|
||||||
| PUT | /functions/{id} | Обновить функцию |
|
|
||||||
| DELETE | /functions/{id} | Удалить функцию |
|
|
||||||
|
|
||||||
### Versions (код функции)
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| GET | /functions/{id}/versions | Список версий |
|
|
||||||
| POST | /functions/{id}/versions | Загрузить новый код (multipart zip) |
|
|
||||||
| GET | /functions/{id}/versions/{ver} | Получить версию |
|
|
||||||
| POST | /functions/{id}/versions/{ver}/activate | Активировать версию |
|
|
||||||
|
|
||||||
### Triggers
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| GET | /functions/{id}/triggers | Список триггеров |
|
|
||||||
| POST | /functions/{id}/triggers | Создать триггер (HTTP/Cron) |
|
|
||||||
| DELETE | /functions/{id}/triggers/{tid} | Удалить триггер |
|
|
||||||
|
|
||||||
### Invocations (вызов и логи)
|
|
||||||
|
|
||||||
| Метод | Путь | Описание |
|
|
||||||
|-------|------|----------|
|
|
||||||
| POST | /functions/{id}/invoke | Синхронный вызов |
|
|
||||||
| GET | /functions/{id}/invocations | История вызовов |
|
|
||||||
| GET | /functions/{id}/invocations/{iid} | Детали вызова + логи |
|
|
||||||
|
|
||||||
## Upload endpoint
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /v1/namespaces/{namespace}/functions/{name}/upload
|
|
||||||
Content-Type: multipart/form-data
|
|
||||||
Authorization: Bearer <token>
|
|
||||||
|
|
||||||
field: code = <zip-file>
|
|
||||||
```
|
|
||||||
|
|
||||||
Процесс:
|
|
||||||
1. Принимает zip (max 32MB)
|
|
||||||
2. Распаковывает zip
|
|
||||||
3. Генерирует `Dockerfile` (`FROM naeel/sless-runtime-{runtime}:latest\nCOPY . /app/function/`)
|
|
||||||
4. Перепаковывает в `tar.gz` (kaniko требует tar format)
|
|
||||||
5. Загружает в S3: `contexts/{ns}/{name}/{timestamp}.tar.gz`
|
|
||||||
6. Обновляет `fn.Spec.S3Key` → контроллер видит изменение и запускает kaniko Job
|
|
||||||
|
|
||||||
Ответ `200 OK`:
|
|
||||||
```json
|
|
||||||
{"message": "build queued", "phase": "Pending", "s3_key": "contexts/..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Поддерживаемые runtime (v1)
|
|
||||||
|
|
||||||
- `python3.11` — реализован и протестирован
|
|
||||||
- `go1.21` — планируется
|
|
||||||
- `nodejs20` — планируется
|
|
||||||
|
|
||||||
## Модель Function
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "fn-uuid",
|
|
||||||
"name": "my-function",
|
|
||||||
"description": "...",
|
|
||||||
"runtime": "python3.11",
|
|
||||||
"entrypoint": "handler.handle",
|
|
||||||
"memory_mb": 128,
|
|
||||||
"timeout_sec": 30,
|
|
||||||
"env_vars": {"KEY": "value"},
|
|
||||||
"active_version": "1",
|
|
||||||
"status": "active",
|
|
||||||
"created_at": "...",
|
|
||||||
"updated_at": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Модель Trigger
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "tr-uuid",
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://sless.api.ngcloud.ru/invoke/fn-uuid",
|
|
||||||
"created_at": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "tr-uuid",
|
|
||||||
"type": "cron",
|
|
||||||
"schedule": "0 * * * *",
|
|
||||||
"created_at": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -2,190 +2,6 @@
|
|||||||
|
|
||||||
> Сюда записываем проблемы с которыми столкнулись и как их решили.
|
> Сюда записываем проблемы с которыми столкнулись и как их решили.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — Баг 3: PodLogOptions compile error (v0.1.31)
|
|
||||||
|
|
||||||
**Проблема:** Оператор не компилировался. Ошибка:
|
|
||||||
```
|
|
||||||
controllers/functionjob_controller.go: unknown field Stdout in corev1.PodLogOptions
|
|
||||||
controllers/functionjob_controller.go: unknown field Stderr in corev1.PodLogOptions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Причина:** `corev1.PodLogOptions{}` в k8s API не имеет полей `Stdout` и `Stderr` — это поля из `corev1.ContainerState`. Код выглядел так:
|
|
||||||
```go
|
|
||||||
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{
|
|
||||||
Stdout: true, // не существует
|
|
||||||
Stderr: true, // не существует
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Решение:** Убрать несуществующие поля. `GetLogs` по умолчанию возвращает stdout+stderr без дополнительных флагов:
|
|
||||||
```go
|
|
||||||
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
|
|
||||||
**Версия:** исправлено в `naeel/sless-operator:v0.1.31`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — Главная причина провала POSTGRES example: неправильный nubes_endpoint
|
|
||||||
|
|
||||||
### Симптом
|
|
||||||
|
|
||||||
`terraform apply` для `examples/POSTGRES` падал с `"job failed, check pod logs"`. При этом `examples/simple-python` и `examples/hello-node` работали нормально на тест-стенде.
|
|
||||||
|
|
||||||
### Корневая причина
|
|
||||||
|
|
||||||
В `examples/POSTGRES/main.tf` в блоке `provider "sless"` был указан **продовый** endpoint Nubes API:
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
provider "sless" {
|
|
||||||
endpoint = "https://sless-api.kube5s.ru"
|
|
||||||
token = var.api_token
|
|
||||||
nubes_endpoint = "https://deck-api.ngcloud.ru/api/v1" # ← ПРОД
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
При этом `provider "nubes"` уже указывал на тест:
|
|
||||||
```hcl
|
|
||||||
provider "nubes" {
|
|
||||||
api_token = var.api_token
|
|
||||||
api_endpoint = "https://deck-api-test.ngcloud.ru/api/v1/index.cfm" # ← тест ✓
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Из-за этого `sless_function` и `sless_job` стучались в **продовый** Nubes API, где у тестового пользователя другой namespace и другой кластер. Postgres и pod запускались в контексте прода, а не тест-стенда. Поды не могли подключиться к базе.
|
|
||||||
|
|
||||||
### Фикс
|
|
||||||
|
|
||||||
```hcl
|
|
||||||
# examples/POSTGRES/main.tf
|
|
||||||
provider "sless" {
|
|
||||||
endpoint = "https://sless-api.kube5s.ru"
|
|
||||||
token = var.api_token
|
|
||||||
nubes_endpoint = "https://deck-api-test.ngcloud.ru/api/v1" # ← тест
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Аналогично исправлено в `examples/simple-python/main.tf`.
|
|
||||||
|
|
||||||
### Почему долго искали
|
|
||||||
|
|
||||||
Долгое расследование ушло на "split-brain" — API-созданные FunctionJob'ы были невидимы через `kubectl get functionjobs -A`. Это объяснялось тем что:
|
|
||||||
1. Job завершался (Failed) за ~5 секунд
|
|
||||||
2. Между завершением и опросом kubectl объект уже мог быть очищен
|
|
||||||
3. Параллельно в кластере фигурировал другой namespace (`sless-ffd1f598c169b0ae` — прод) vs тест-namespace
|
|
||||||
|
|
||||||
На самом деле контроллер работал корректно. Проблема была исключительно в `nubes_endpoint`.
|
|
||||||
|
|
||||||
### Урок
|
|
||||||
|
|
||||||
**Всегда проверять**: когда в `main.tf` два провайдера (`nubes` + `sless`) — у обоих endpoint'ы должны указывать на **одну среду**. Смешивание прод/тест endpoint'ов в одном apply даёт непредсказуемые результаты.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — Ошибка агента: локальный запуск terraform
|
|
||||||
|
|
||||||
**Проблема:** Агент запускал `terraform apply` **локально** (`/home/naeel/remote_dev/sless/examples/...`) вместо запуска на удалённом сервере `naeel@5.172.178.213`.
|
|
||||||
|
|
||||||
**Почему неправильно:**
|
|
||||||
- Провайдер `terra.k8c.ru/naeel/sless` установлен только на удалённом сервере
|
|
||||||
- Локальный terraform не имеет доступа к k8s кластеру напрямую
|
|
||||||
- Потенциально затрагивал продовые API из локальной сети
|
|
||||||
|
|
||||||
**Правило:** Все `terraform init/plan/apply/destroy` для `examples/` — только через SSH на `naeel@5.172.178.213`:
|
|
||||||
```bash
|
|
||||||
ssh -i /home/naeel/remote_dev/common/id_ed25519.txt naeel@5.172.178.213 \
|
|
||||||
'cd /home/naeel/terra/sless/examples/<example> && terraform apply -auto-approve -no-color'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-17 — FunctionJob всегда "job failed, check pod logs" — расследование и два бага
|
|
||||||
|
|
||||||
### Хронология расследования
|
|
||||||
|
|
||||||
**Симптом:** `terraform apply` на `examples/POSTGRES` завершается ошибкой:
|
|
||||||
```
|
|
||||||
job failed: job failed, check pod logs: kubectl logs -n sless-fn-sless-ffd1f598c169b0ae -l functionjob=pg-create-table-job-main-v12
|
|
||||||
```
|
|
||||||
Команда из сообщения — ничего не выдаёт (под не найден).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ошибка агента №1 — SQL-диагностика вместо анализа кода**
|
|
||||||
|
|
||||||
Первая реакция — запустить диагностические поды в кластере с psycopg2 чтобы проверить подключение к PostgreSQL. Это было **неправильно**: проблема не в подключении, а в том что job не запускался вообще. Пользователь остановил — "ПРИ ЧЁМ тут SQL запросы???".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Ошибка агента №2 — не читал собственный код**
|
|
||||||
|
|
||||||
Раньше читались логи оператора и kubectl describe — но не исходный код контроллеров. Пользователь указал: "мы пишем ВСЁ сами, смотри в код". После прочтения `functionjob_controller.go` и `functions.go` картина сложилась за одно чтение.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Баг 1 — k8s label `job-name` удалён в 1.27+
|
|
||||||
|
|
||||||
**Файл:** `controllers/functionjob_controller.go`, функция `getJobPodOutput`
|
|
||||||
|
|
||||||
**Код до:**
|
|
||||||
```go
|
|
||||||
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
|
|
||||||
LabelSelector: "job-name=" + jobName,
|
|
||||||
})
|
|
||||||
if err != nil || len(pods.Items) == 0 {
|
|
||||||
return "completed successfully" // ← сюда всегда попадали
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Причина:** В k8s 1.27 лейбл `job-name` на pod-ах был deprecated, в 1.32+ удалён полностью. У нас кластер **1.34.1**. Поэтому `List` возвращал 0 подов всегда → fallback `"completed successfully"` → при `phase=Failed` финальное сообщение `"job failed, check pod logs: ..."`.
|
|
||||||
|
|
||||||
Логи реального сбоя никогда не попадали в `status.Message` FunctionJob, поэтому **корневая ошибка Python-функции была невидима**.
|
|
||||||
|
|
||||||
**Как нашли:** Проверили `kubectl get pod -l "batch.kubernetes.io/job-name"` — вернул поды. `kubectl get pod -l "job-name"` — не вернул. Версия кластера: `v1.34.1` (kubectl `v1.35.2`).
|
|
||||||
|
|
||||||
**Фикс:** Использовать собственный лейбл `functionjob=<fj.Name>`, который мы сами выставляем на PodTemplate и который работает независимо от версии k8s.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Также:** В строке 211 подсказка для пользователя тоже использовала устаревший лейбл:
|
|
||||||
```go
|
|
||||||
"job failed, check pod logs: kubectl logs -n " + job.Namespace + " -l job-name=" + job.Name
|
|
||||||
```
|
|
||||||
Исправлено на `functionjob=<fj.Name>` — теперь команда реально работает.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Баг 2 — Split-brain cached client при CreateFunction
|
|
||||||
|
|
||||||
**Файл:** `internal/api/handler/functions.go`, функция `CreateFunction`
|
|
||||||
|
|
||||||
**Проявление:** API возвращает `409 "function already exists"`, а `kubectl get function -A` функцию не видит. `terraform state rm sless_function.*` не помогает — следующий `terraform apply` снова получает 409.
|
|
||||||
|
|
||||||
**Причина:** `h.K8s` в handler — это **cached client** controller-runtime. Цепочка:
|
|
||||||
1. Предыдущий `terraform destroy` вызвал `DELETE /functions/pg-create-table-runner`
|
|
||||||
2. Handler вызвал `h.K8s.Delete(ctx, fn)` — объект удалён из **etcd**
|
|
||||||
3. Кеш controller-runtime обновляется асинхронно (informer watch). Несколько секунд объект ещё жив в памяти оператора
|
|
||||||
4. Следующий `terraform apply` → `POST /functions` → `h.K8s.Create(ctx, fn)` → `IsAlreadyExists` (кеш ещё видит объект)
|
|
||||||
5. Код проверяет `phase == Failed` — но объект в кеше в фазе `Ready` → уходит в 409 навсегда
|
|
||||||
|
|
||||||
`kubectl get function` шёл **мимо кеша** (прямо в k8s API) → NotFound. API handler шёл **через кеш** → AlreadyExists. Поэтому они показывали разные результаты.
|
|
||||||
|
|
||||||
**Фикс:** При `IsAlreadyExists` делать `uncached Get` (через `client.ObjectKey` напрямую). Если объект реально не найден в etcd (`IsNotFound`) — значит кеш устарел, смело создаём заново. Если найден — возвращаем 409 как обычно (объект реально существует).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Итог
|
|
||||||
|
|
||||||
| # | Баг | Файл | Строка | Тип |
|
|
||||||
|---|-----|------|--------|-----|
|
|
||||||
| 1 | `LabelSelector: "job-name="` deprecated k8s 1.27+ | `controllers/functionjob_controller.go` | 311 | Совместимость |
|
|
||||||
| 1b | Подсказка `kubectl logs -l job-name=` тоже устарела | `controllers/functionjob_controller.go` | 211 | UX |
|
|
||||||
| 2 | Cached client → perpetual 409 при CreateFunction | `internal/api/handler/functions.go` | 117-133 | Race condition |
|
|
||||||
|
|
||||||
## Шаблон записи
|
## Шаблон записи
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
-204
@@ -1,204 +0,0 @@
|
|||||||
# Архитектура системы
|
|
||||||
|
|
||||||
Последнее обновление: 2026-03-18 (v0.1.33 + funcs-service v0.1.3)
|
|
||||||
|
|
||||||
## Общее описание
|
|
||||||
|
|
||||||
Managed Serverless Functions Service для облачного провайдера nubes.ru.
|
|
||||||
Пользователь загружает код через Terraform, сервис его собирает (kaniko) и запускает
|
|
||||||
по HTTP-триггеру, расписанию (cron) или вручную через one-shot Job.
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
| Компонент | Технология | Где запущен |
|
|
||||||
|-----------|-----------|-------------|
|
|
||||||
| Operator (API + Controllers) | Go (controller-runtime) | Kubernetes, namespace `sless` |
|
|
||||||
| funcs-service (глобальная консоль) | Go (net/http) | Kubernetes, namespace `sless` |
|
|
||||||
| PostgreSQL | PostgreSQL 16 | Kubernetes, namespace `sless` |
|
|
||||||
| S3 | Ceph (облачный) | `s3.msk-1.ngcloud.ru` |
|
|
||||||
| Container Registry | DockerHub (`naeel/`) | внешний |
|
|
||||||
| Builder | kaniko (k8s Job) | namespace пользователя |
|
|
||||||
| Функции (HTTP) | k8s Deployment + Service | namespace пользователя |
|
|
||||||
| Функции (one-shot) | k8s Job | namespace пользователя |
|
|
||||||
| Функции (cron) | k8s CronJob | namespace пользователя |
|
|
||||||
| Terraform Provider | Go (plugin framework v6) | localhost/CI |
|
|
||||||
| nubes API | REST (облако) | `deck-api.ngcloud.ru` |
|
|
||||||
|
|
||||||
> Redis и RabbitMQ — отложены до v2.
|
|
||||||
|
|
||||||
## Компонент: funcs-service
|
|
||||||
|
|
||||||
Глобальный HTTP сервис — **одна копия** на весь кластер, для всех пользователей.
|
|
||||||
|
|
||||||
```
|
|
||||||
User Browser / curl
|
|
||||||
└─► https://sless.kube5s.ru/funcs/<namespace>
|
|
||||||
└─► nginx Ingress (sless-funcs-ingress)
|
|
||||||
└─► sless-funcs-service:8090 (namespace sless)
|
|
||||||
└─► http://sless-operator.sless.svc.cluster.local:9090/v1/...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Файлы:**
|
|
||||||
- `services/funcs/main.go` — логика
|
|
||||||
- `services/funcs/Dockerfile` — multi-stage Go → alpine
|
|
||||||
- `deployments/k8s/funcs-service.yaml` — Deployment + Service + Ingress
|
|
||||||
|
|
||||||
**Env vars сервиса:**
|
|
||||||
| Переменная | Значение |
|
|
||||||
|-----------|---------|
|
|
||||||
| `SLESS_OPERATOR_URL` | `http://sless-operator.sless.svc.cluster.local:9090` |
|
|
||||||
| `SLESS_EXTERNAL_URL` | `https://sless.kube5s.ru` |
|
|
||||||
| `SLESS_EXCLUDE` | `event-writer,event-monitor,event-cleaner` |
|
|
||||||
| `SLESS_SERVICE_TOKEN` | JWT токен (задаётся через `kubectl set env`, не в git) |
|
|
||||||
|
|
||||||
## Хранение кода функций в S3
|
|
||||||
|
|
||||||
```
|
|
||||||
Terraform source_dir (локально)
|
|
||||||
└─► zip → POST /upload → builder.PrepareContext()
|
|
||||||
├─► functions/{ns}/{name}/{ts}.zip ← ИСХОДНЫЙ КОД пользователя
|
|
||||||
└─► contexts/{ns}/{name}/{ts}.tar.gz ← BUILD CONTEXT для kaniko
|
|
||||||
└─► Function CRD: spec.s3Key = "contexts/..."
|
|
||||||
└─► контроллер → kaniko Job → Docker image
|
|
||||||
```
|
|
||||||
|
|
||||||
Для web-консоли: `GET /source` читает `functions/{ns}/{name}/{ts}.zip` напрямую.
|
|
||||||
|
|
||||||
## Изоляция пользователей — Namespace per user
|
|
||||||
|
|
||||||
Каждый пользователь облака получает **отдельный k8s namespace**.
|
|
||||||
|
|
||||||
```
|
|
||||||
JWT токен (Bearer)
|
|
||||||
└─► JWT.sub (строка "0199e325-1cdf-7cda-9319-e5302a85e291")
|
|
||||||
└─► SHA256(sub) → первые 8 байт → hex → "sless-{16 hex символов}"
|
|
||||||
└─► namespace = "sless-cdd874dfa31ba6ca"
|
|
||||||
```
|
|
||||||
|
|
||||||
- Namespace детерминирован: один sub → всегда один namespace.
|
|
||||||
- sub не раскрывается в имени namespace (SHA256 необратим).
|
|
||||||
- Длина 22 символа — укладывается в лимит k8s (63).
|
|
||||||
|
|
||||||
**Кто создаёт namespace:**
|
|
||||||
Terraform провайдер при Configure() вызывает POST /v1/namespaces/{ns}/ensure
|
|
||||||
**один раз**, до любых ресурсных операций.
|
|
||||||
Resource-хендлеры (Function, Trigger, Job) namespace **не создают** — это не их ответственность.
|
|
||||||
|
|
||||||
## Аутентификация
|
|
||||||
|
|
||||||
### Оператор (REST API)
|
|
||||||
- Bearer JWT в заголовке Authorization
|
|
||||||
- Проверяется структура JWT (3 части), наличие sub claim, срок действия exp
|
|
||||||
- Подпись **не проверяется** — trusted perimeter (оператор за Ingress)
|
|
||||||
|
|
||||||
### Terraform Provider (при Configure)
|
|
||||||
1. Декодирует JWT → sub
|
|
||||||
2. Вычисляет namespace через SHA256
|
|
||||||
3. Если задан nubes_endpoint — пингует nubes API (GET <nubes_endpoint>) с тем же токеном
|
|
||||||
- HTTP 401/403 → ошибка инициализации провайдера
|
|
||||||
- Недоступен → ошибка инициализации
|
|
||||||
4. Вызывает POST /v1/namespaces/{ns}/ensure (создаёт namespace если нет)
|
|
||||||
|
|
||||||
## Схема вызова
|
|
||||||
|
|
||||||
```
|
|
||||||
Пользователь (curl / браузер)
|
|
||||||
|
|
|
||||||
v GET|POST|... /fn/{namespace}/{name}/*
|
|
||||||
sless-api Ingress -> Operator /fn/ прокси
|
|
||||||
|
|
|
||||||
v HTTP forward -> http://{name}.{namespace}.svc.cluster.local:8080
|
|
||||||
k8s Service -> Deployment/Pod функции
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
terraform apply
|
|
||||||
|
|
|
||||||
v provider Configure()
|
|
||||||
1. JWT -> sub -> namespace
|
|
||||||
2. PingNubesAPI (валидация токена)
|
|
||||||
3. POST /v1/namespaces/{ns}/ensure <- создаёт k8s namespace
|
|
||||||
|
|
|
||||||
+-> POST /v1/namespaces/{ns}/functions <- создаёт Function CRD
|
|
||||||
| +-> POST /upload (zip) <- загружает код -> S3 -> kaniko Job
|
|
||||||
| +-> polling phase=Ready
|
|
||||||
|
|
|
||||||
+-> POST /v1/namespaces/{ns}/triggers <- создаёт Trigger CRD
|
|
||||||
| +-> controller: Deployment + Service + (CronJob для cron)
|
|
||||||
|
|
|
||||||
+-> POST /v1/namespaces/{ns}/jobs <- создаёт FunctionJob CRD
|
|
||||||
+-> controller: k8s Job -> result в status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Структура кода
|
|
||||||
|
|
||||||
```
|
|
||||||
sless/
|
|
||||||
|-- main.go точка входа: k8s manager + REST API сервер (goroutine)
|
|
||||||
|-- internal/
|
|
||||||
| |-- api/
|
|
||||||
| | |-- router.go gorilla/mux: /fn/ (публичный), /v1/ (auth + middleware)
|
|
||||||
| | |-- handler/
|
|
||||||
| | | |-- handler.go Handler struct + helpers (writeJSON, namespace(), pathVar())
|
|
||||||
| | | |-- namespace.go EnsureNamespace (POST /v1/namespaces/{ns}/ensure)
|
|
||||||
| | | |-- functions.go CRUD Function
|
|
||||||
| | | |-- triggers.go CRUD Trigger
|
|
||||||
| | | |-- jobs.go CRUD FunctionJob
|
|
||||||
| | | |-- upload.go zip -> Dockerfile -> tar.gz -> S3 -> CRD patch
|
|
||||||
| | | |-- invoke.go прокси /fn/{ns}/{name} -> in-cluster DNS
|
|
||||||
| | | +-- invocations.go 501 stub (реализация отложена)
|
|
||||||
| | +-- middleware/
|
|
||||||
| | |-- auth.go JWT validation (struct + sub + exp, подпись не проверяется)
|
|
||||||
| | +-- logging.go slog request logger
|
|
||||||
| |-- builder/
|
|
||||||
| | |-- builder.go kaniko Job lifecycle (Build, JobStatus, Cleanup)
|
|
||||||
| | +-- context.go PrepareContext: zip+runtime → tar.gz+Dockerfile для kaniko
|
|
||||||
| |-- config/config.go Load() из env vars
|
|
||||||
| +-- storage/
|
|
||||||
| |-- postgres/store.go SaveInvocation, ListInvocations, RunMigrations
|
|
||||||
| +-- s3/client.go Upload, Download, UploadContext (tar.gz для kaniko)
|
|
||||||
|-- controllers/
|
|
||||||
| |-- function_controller.go Reconcile: Pending->Building->Ready/Failed + Deployment
|
|
||||||
| |-- trigger_controller.go Reconcile: Service+Ingress (http) / CronJob (cron)
|
|
||||||
| +-- functionjob_controller.go Reconcile: k8s Job -> Succeeded/Failed + output capture
|
|
||||||
|-- api/v1alpha1/
|
|
||||||
| |-- function_types.go Function CRD
|
|
||||||
| |-- trigger_types.go Trigger CRD
|
|
||||||
| +-- job_types.go FunctionJob CRD
|
|
||||||
|-- deployments/k8s/
|
|
||||||
| |-- operator.yaml Deployment + Service + Ingress
|
|
||||||
| +-- rbac.yaml ClusterRole + ClusterRoleBinding + ServiceAccount
|
|
||||||
|-- terraform/provider/ независимый Go-модуль
|
|
||||||
| +-- internal/
|
|
||||||
| |-- client/client.go SubFromJWT, NamespaceFromSub, PingNubesAPI + CRUD
|
|
||||||
| |-- provider/provider.go Configure(): JWT->NS->ping->EnsureNamespace
|
|
||||||
| +-- resources/
|
|
||||||
| |-- function_resource.go sless_function
|
|
||||||
| |-- trigger_resource.go sless_trigger
|
|
||||||
| +-- job_resource.go sless_job
|
|
||||||
+-- runtimes/
|
|
||||||
|-- python3.11/ server.py + Dockerfile -> naeel/sless-runtime-python3.11:v0.1.1
|
|
||||||
+-- nodejs20/ server.js + Dockerfile -> naeel/sless-runtime-nodejs20:v0.1.2
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kubernetes кластер
|
|
||||||
|
|
||||||
Сейчас используется существующий кластер (временный).
|
|
||||||
Планируется переезд на новый кластер — манифесты переносятся без изменений.
|
|
||||||
|
|
||||||
Ноды:
|
|
||||||
- wheel-control-plane-fm9sr — control-plane
|
|
||||||
- wheel-workers-tv4qr-r45xs — worker
|
|
||||||
- wheel-workers-tv4qr-x8xw7 — worker
|
|
||||||
|
|
||||||
Ingress: nginx, external IP 5.172.178.182
|
|
||||||
API endpoint: https://sless-api.kube5s.ru
|
|
||||||
|
|
||||||
## Версии в production
|
|
||||||
|
|
||||||
| Артефакт | Тег/Версия |
|
|
||||||
|---------|-----------|
|
|
||||||
| naeel/sless-operator | v0.1.22 |
|
|
||||||
| terra.k8c.ru/naeel/sless провайдер | v0.1.13 |
|
|
||||||
| naeel/sless-runtime-python3.11 | v0.1.1 |
|
|
||||||
| naeel/sless-runtime-nodejs20 | v0.1.2 |
|
|
||||||
+1
-134
@@ -1,139 +1,6 @@
|
|||||||
# Прогресс разработки
|
# Прогресс разработки
|
||||||
|
|
||||||
Последнее обновление: 2026-03-19 09:00
|
Последнее обновление: 2026-03-18 12:00 (DNS инцидент устранён, POSTGRES E2E PASS)
|
||||||
|
|
||||||
## 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 | Оператор: `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
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 1 | Python runtime str→text/plain | ✅ | `runtimes/python3.11/server.py`: если функция возвращает `str` → `Content-Type: text/plain`. Образ `naeel/sless-runtime-python3.11:v0.1.3` |
|
|
||||||
| 2 | funcs_list.py: plain text вывод | ✅ | Переписан с JSON на plain text с `─` разделителями, фильтр `SLESS_EXCLUDE` |
|
|
||||||
| 3 | Оператор v0.1.33 | ✅ | context.go: python runtime → v0.1.3; `internal/builder/context.go` обновлён |
|
|
||||||
| 4 | `funcs` как глобальный Go сервис | ✅ | `services/funcs/main.go` — standalone Go HTTP сервер. Деплоится ОДИН РАЗ в namespace `sless`, работает для ВСЕХ пользователей. Образ `naeel/sless-funcs-service:v0.1.3` |
|
|
||||||
| 5 | URL без токена | ✅ | `https://sless.kube5s.ru/funcs/<namespace>` — открывается в браузере без токена. `SLESS_SERVICE_TOKEN` задан через `kubectl set env` |
|
|
||||||
| 6 | Удалить funcs из terraform | ✅ | `examples/POSTGRES/resources.tf`: удалены `sless_function.funcs_list`, `sless_trigger.funcs_list_http`, `output "funcs_url"`, `local.user_namespace`. `terraform apply` — 2 destroyed |
|
|
||||||
| 7 | Ветка feat/web-console | ✅ | Создана от `feat/harbor-integration` на remote, запушена |
|
|
||||||
| 8 | Документация архитектуры хранения кода | ✅ | См. `doc/decisions/log.md` раздел "Хранение кода функций и web-консоль" |
|
|
||||||
| 9 | Коммиты | ✅ | `e8cd62e` (funcs Go service), `38bb494` (v0.1.3 — /funcs/namespace без токена) |
|
|
||||||
|
|
||||||
### Текущие URL функций
|
|
||||||
|
|
||||||
```
|
|
||||||
https://sless.kube5s.ru/funcs/sless-ffd1f598c169b0ae # список функций (без токена)
|
|
||||||
https://sless.kube5s.ru/funcs?token=<jwt> # то же, через токен
|
|
||||||
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-info
|
|
||||||
https://sless.kube5s.ru/fn/sless-ffd1f598c169b0ae/pg-table-reader
|
|
||||||
```
|
|
||||||
|
|
||||||
### Образы
|
|
||||||
|
|
||||||
| Образ | Версия | Что изменилось |
|
|
||||||
|-------|--------|----------------|
|
|
||||||
| `naeel/sless-operator` | v0.1.33 | created_at, last_built_at в API; python runtime v0.1.3 |
|
|
||||||
| `naeel/sless-runtime-python3.11` | v0.1.3 | str → text/plain в server.py |
|
|
||||||
| `naeel/sless-funcs-service` | v0.1.3 | /funcs/<namespace> без токена, /health probe |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-18 — Следующие шаги: web-консоль (ветка feat/web-console)
|
|
||||||
|
|
||||||
| # | Задача | Статус | Заметки |
|
|
||||||
|---|--------|--------|---------|
|
|
||||||
| 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 | ✅ | Задеплоен |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-03-18 12:00 — DNS инцидент устранён, POSTGRES E2E PASS
|
|
||||||
|
|
||||||
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
|
## 2026-03-18 — DNS инцидент: sless-api.kube5s.ru → мёртвый кластер
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
# 2026-03-18 (обновлено: plain text вывод; фильтрация SLESS_EXCLUDE)
|
# 2026-03-18 (обновлено: фильтрация SLESS_EXCLUDE, читаемый вывод через "#"-ключ)
|
||||||
# funcs_list.py — HTTP-функция: список пользовательских функций, человекочитаемый plain text.
|
# funcs_list.py — HTTP-функция: список всех пользовательских функций с их статусами.
|
||||||
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
# Вызывает внутренний REST API оператора (ClusterIP, без TLS).
|
||||||
# Возвращает str → python runtime отдаёт text/plain напрямую без json.dumps.
|
# Объединяет данные функций и триггеров в один ответ; скрывает служебные функции.
|
||||||
#
|
#
|
||||||
# Env vars:
|
# Env vars:
|
||||||
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
# SLESS_API_URL — URL оператора (http://sless-operator.sless.svc.cluster.local:9090)
|
||||||
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
# SLESS_NAMESPACE — namespace пользователя (sless-{hex16})
|
||||||
# SLESS_TOKEN — JWT токен для /v1/ API
|
# SLESS_TOKEN — JWT токен для /v1/ API
|
||||||
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru)
|
# SLESS_EXTERNAL_URL — публичный базовый URL (https://sless.kube5s.ru), для корректных ссылок
|
||||||
# SLESS_EXCLUDE — comma-separated имена функций, которые не показывать
|
# SLESS_EXCLUDE — comma-separated имена функций, которые не надо показывать
|
||||||
|
# Пример: "funcs,event-writer,event-monitor,event-cleaner"
|
||||||
|
#
|
||||||
|
# Формат вывода: JSON-объект, где каждая функция содержит поле "#" — краткий комментарий.
|
||||||
|
# При pretty-print (python3 -m json.tool) выглядит как читаемый список с аннотациями.
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
SEP = "─" * 52
|
|
||||||
|
|
||||||
|
def _short_comment(fn, http_triggers, cron_triggers):
|
||||||
|
"""Генерирует однострочный комментарий-описание функции по её метаданным."""
|
||||||
|
phase = fn.get("phase", "")
|
||||||
|
runtime = fn.get("runtime", "")
|
||||||
|
|
||||||
def _comment(fn, http_trigs, cron_trigs):
|
if http_triggers:
|
||||||
phase = fn.get("phase", "?")
|
active_str = "активна" if http_triggers[0].get("active") else "неактивна"
|
||||||
runtime = fn.get("runtime", "?")
|
return f"HTTP endpoint ({runtime}) — {phase}, {active_str}"
|
||||||
if http_trigs:
|
elif cron_triggers:
|
||||||
active = "активна" if http_trigs[0].get("active") else "неактивна"
|
schedule = cron_triggers[0].get("schedule", "?")
|
||||||
return f"HTTP endpoint ({runtime}) — {phase}, {active}"
|
active_str = "активна" if cron_triggers[0].get("active") else "неактивна"
|
||||||
elif cron_trigs:
|
return f"Cron '{schedule}' ({runtime}) — {phase}, {active_str}"
|
||||||
schedule = cron_trigs[0].get("schedule", "?")
|
|
||||||
active = "активна" if cron_trigs[0].get("active") else "неактивна"
|
|
||||||
return f"Cron '{schedule}' ({runtime}) — {phase}, {active}"
|
|
||||||
else:
|
else:
|
||||||
return f"Job/runner без триггера ({runtime}) — {phase}"
|
return f"Job/runner без триггера ({runtime}) — {phase}"
|
||||||
|
|
||||||
@@ -35,60 +39,91 @@ def list_all(event):
|
|||||||
namespace = os.environ["SLESS_NAMESPACE"]
|
namespace = os.environ["SLESS_NAMESPACE"]
|
||||||
token = os.environ["SLESS_TOKEN"]
|
token = os.environ["SLESS_TOKEN"]
|
||||||
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
ext_url = os.environ.get("SLESS_EXTERNAL_URL", "").rstrip("/")
|
||||||
exclude = {n.strip() for n in os.environ.get("SLESS_EXCLUDE", "").split(",") if n.strip()}
|
|
||||||
|
# Имена функций, которые не должны присутствовать в выводе.
|
||||||
|
# Включает саму себя ("funcs") и служебные функции других примеров.
|
||||||
|
exclude = {
|
||||||
|
n.strip()
|
||||||
|
for n in os.environ.get("SLESS_EXCLUDE", "").split(",")
|
||||||
|
if n.strip()
|
||||||
|
}
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
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()
|
|
||||||
|
|
||||||
trig_idx = {}
|
fns_resp = requests.get(
|
||||||
for tr in trs.json():
|
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():
|
||||||
fn_name = tr.get("function") or tr.get("functionRef")
|
fn_name = tr.get("function") or tr.get("functionRef")
|
||||||
if fn_name:
|
if fn_name:
|
||||||
trig_idx.setdefault(fn_name, []).append(tr)
|
triggers_by_fn.setdefault(fn_name, []).append(tr)
|
||||||
|
|
||||||
items = []
|
result = []
|
||||||
for fn in fns.json():
|
for fn in fns_resp.json():
|
||||||
name = fn["name"]
|
name = fn["name"]
|
||||||
if name in exclude:
|
if name in exclude:
|
||||||
continue
|
continue
|
||||||
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"]
|
http_triggers = [
|
||||||
is_active = any(t.get("enabled", True) and t.get("active", False) for t in trig_idx.get(name, []))
|
t for t in triggers_by_fn.get(name, []) if t.get("type") == "http"
|
||||||
items.append((fn, http_t, cron_t, is_active))
|
]
|
||||||
|
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)
|
||||||
|
|
||||||
# Сортировка: активные вверх, затем по имени
|
# Сортировка: активные вверх, затем по имени
|
||||||
items.sort(key=lambda x: (not x[3], x[0]["name"]))
|
result.sort(key=lambda f: (not f["active"], f["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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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()
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# 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,14 +126,13 @@ resource "sless_trigger" "pg_info_http" {
|
|||||||
enabled = true
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTTP-функции чтения и записи строк terraform_demo_table — в одном файле table_rw.py.
|
# HTTP-функция читает строки из terraform_demo_table и возвращает JSON.
|
||||||
# list_rows (GET) — читает все строки; add_row (POST {title}) — вставляет строку.
|
# Использует те же credentials что и sql-runner.
|
||||||
# Доступны по URL: https://sless.kube5s.ru/fn/<namespace>/pg-table-reader
|
# Доступна по 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" {
|
resource "sless_function" "postgres_table_reader" {
|
||||||
name = "pg-table-reader"
|
name = "pg-table-reader"
|
||||||
runtime = "python3.11"
|
runtime = "python3.11"
|
||||||
entrypoint = "table_rw.list_rows"
|
entrypoint = "table_reader.list_rows"
|
||||||
memory_mb = 128
|
memory_mb = 128
|
||||||
timeout_sec = 30
|
timeout_sec = 30
|
||||||
|
|
||||||
@@ -146,7 +145,7 @@ resource "sless_function" "postgres_table_reader" {
|
|||||||
PGSSLMODE = "require"
|
PGSSLMODE = "require"
|
||||||
}
|
}
|
||||||
|
|
||||||
source_dir = "${path.module}/code/table-rw"
|
source_dir = "${path.module}/code/table-reader"
|
||||||
|
|
||||||
depends_on = [sless_job.postgres_table_init_job]
|
depends_on = [sless_job.postgres_table_init_job]
|
||||||
}
|
}
|
||||||
@@ -162,35 +161,4 @@ output "table_reader_url" {
|
|||||||
value = sless_trigger.postgres_table_reader_http.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,13 +66,10 @@ func (h *Handler) InvokeFunction(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пробрасываем Content-Type и Content-Length если есть.
|
// Пробрасываем Content-Type если есть
|
||||||
// Content-Length обязателен: Python BaseHTTPRequestHandler читает тело
|
|
||||||
// ровно столько байт, сколько указано в заголовке; без него body = пусто.
|
|
||||||
if ct := r.Header.Get("Content-Type"); ct != "" {
|
if ct := r.Header.Get("Content-Type"); ct != "" {
|
||||||
proxyReq.Header.Set("Content-Type", ct)
|
proxyReq.Header.Set("Content-Type", ct)
|
||||||
}
|
}
|
||||||
proxyReq.ContentLength = r.ContentLength
|
|
||||||
|
|
||||||
resp, err := httpClient.Do(proxyReq)
|
resp, err := httpClient.Do(proxyReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
// Создано: 2026-04-25
|
|
||||||
// source.go — handler для GET /v1/namespaces/{namespace}/functions/{name}/source
|
|
||||||
// Возвращает список файлов исходного кода функции из tar.gz контекста сборки (S3).
|
|
||||||
// Dockerfile исключается — он сгенерирован builder-ом, не является кодом пользователя.
|
|
||||||
// Если код ещё не загружен (S3Key пустой) — возвращает пустой список.
|
|
||||||
|
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"archive/tar"
|
|
||||||
"compress/gzip"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"k8s.io/apimachinery/pkg/api/errors"
|
|
||||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
||||||
|
|
||||||
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sourceFileEntry — один файл из исходного кода функции.
|
|
||||||
type sourceFileEntry struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Content string `json:"content"` // UTF-8 текст; бинарные файлы — base64 encoded
|
|
||||||
Binary bool `json:"binary"` // true если файл бинарный (base64 в content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSource — GET /v1/namespaces/{namespace}/functions/{name}/source
|
|
||||||
// Скачивает tar.gz контекст сборки из S3 и возвращает пользовательские файлы (без Dockerfile).
|
|
||||||
func (h *Handler) GetSource(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ns := namespace(r)
|
|
||||||
name := pathVar(r, "name")
|
|
||||||
|
|
||||||
fn := &slessv1alpha1.Function{}
|
|
||||||
if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, fn); err != nil {
|
|
||||||
if errors.IsNotFound(err) {
|
|
||||||
writeJSON(w, http.StatusNotFound, errResp("function not found"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Код ещё не был загружен — возвращаем пустой список без ошибки
|
|
||||||
if fn.Spec.S3Key == "" {
|
|
||||||
writeJSON(w, http.StatusOK, []sourceFileEntry{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := h.S3.Download(r.Context(), fn.Spec.S3Key)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("download from S3: "+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rc.Close()
|
|
||||||
|
|
||||||
files, err := extractSourceFilesFromTarGz(rc)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusInternalServerError, errResp("extract source: "+err.Error()))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, files)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractSourceFilesFromTarGz читает tar.gz и возвращает пользовательские файлы.
|
|
||||||
// Dockerfile пропускается — он генерируется builder-ом автоматически.
|
|
||||||
// Бинарные файлы (содержат null bytes) возвращаются в base64 с binary=true.
|
|
||||||
func extractSourceFilesFromTarGz(r io.Reader) ([]sourceFileEntry, error) {
|
|
||||||
gr, err := gzip.NewReader(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("gzip: %w", err)
|
|
||||||
}
|
|
||||||
defer gr.Close()
|
|
||||||
|
|
||||||
tr := tar.NewReader(gr)
|
|
||||||
var result []sourceFileEntry
|
|
||||||
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("tar next: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dockerfile сгенерирован builder-ом — не показываем пользователю
|
|
||||||
if hdr.Name == "Dockerfile" || hdr.FileInfo().IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := io.ReadAll(tr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read %s: %w", hdr.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Бинарные файлы — base64, текстовые — как есть
|
|
||||||
content := string(data)
|
|
||||||
isBinary := strings.ContainsRune(content, '\x00')
|
|
||||||
if isBinary {
|
|
||||||
content = base64.StdEncoding.EncodeToString(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, sourceFileEntry{
|
|
||||||
Name: hdr.Name,
|
|
||||||
Content: content,
|
|
||||||
Binary: isBinary,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if result == nil {
|
|
||||||
result = []sourceFileEntry{}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Изменено: 2026-04-25
|
// Изменено: 2026-03-08
|
||||||
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
|
// router.go — регистрация всех REST-маршрутов через gorilla/mux.
|
||||||
// Все маршруты защищены Bearer-токеном (middleware.Auth).
|
// Все маршруты защищены Bearer-токеном (middleware.Auth).
|
||||||
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
|
// Маршруты сгруппированы по /v1/namespaces/{namespace}/...
|
||||||
@@ -45,9 +45,6 @@ func NewRouter(h *handler.Handler, log *slog.Logger) http.Handler {
|
|||||||
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
|
// Upload code — принимает zip, генерирует Dockerfile, кладёт tar.gz в S3, запускает сборку
|
||||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
|
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/upload", h.UploadCode).Methods(http.MethodPost)
|
||||||
|
|
||||||
// Source code — возвращает файлы из tar.gz контекста сборки (без Dockerfile)
|
|
||||||
v1.HandleFunc("/namespaces/{namespace}/functions/{name}/source", h.GetSource).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
// Triggers CRUD
|
// Triggers CRUD
|
||||||
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
|
v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet)
|
||||||
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
|
v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string)
|
|||||||
Args: []string{
|
Args: []string{
|
||||||
"--context=" + s3ContextURL,
|
"--context=" + s3ContextURL,
|
||||||
"--destination=" + imageRef,
|
"--destination=" + imageRef,
|
||||||
"--no-cache", // без кэша — гарантирует что COPY берёт свежий код из S3
|
"--cache=true", // кешируем слои для ускорения повторных сборок
|
||||||
},
|
},
|
||||||
Env: []corev1.EnvVar{
|
Env: []corev1.EnvVar{
|
||||||
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
|
{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) {
|
func runtimeBaseImage(runtime string) (string, error) {
|
||||||
switch runtime {
|
switch runtime {
|
||||||
case "python3.11":
|
case "python3.11":
|
||||||
return "naeel/sless-runtime-python3.11:v0.1.4", nil
|
return "naeel/sless-runtime-python3.11:v0.1.2", nil
|
||||||
case "nodejs20":
|
case "nodejs20":
|
||||||
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
return "naeel/sless-runtime-nodejs20:v0.1.2", nil
|
||||||
case "go1.23":
|
case "go1.23":
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# Изменено: 2026-03-18 (string return → text/plain без json.dumps)
|
# Изменено: 2026-03-11
|
||||||
# HTTP-обёртка для serverless функций на Python 3.11.
|
# HTTP-обёртка для serverless функций на Python 3.11.
|
||||||
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
|
# Загружает модуль из SLESS_ENTRYPOINT или handler.py по умолчанию.
|
||||||
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
|
# Формат SLESS_ENTRYPOINT: "module_name.func_name" (например: handler.handle)
|
||||||
@@ -56,8 +56,6 @@ class FunctionHandler(BaseHTTPRequestHandler):
|
|||||||
event['_path'] = parsed.path
|
event['_path'] = parsed.path
|
||||||
event['_query'] = query
|
event['_query'] = query
|
||||||
event['_method'] = self.command
|
event['_method'] = self.command
|
||||||
# Accept заголовок — позволяет функции различать браузер и curl/API.
|
|
||||||
event['_accept'] = self.headers.get('Accept', '')
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -112,18 +110,9 @@ class FunctionHandler(BaseHTTPRequestHandler):
|
|||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _respond(self, status, data):
|
def _respond(self, status, data):
|
||||||
# Если функция вернула строку:
|
body = json.dumps(data).encode("utf-8")
|
||||||
# начинается с '<' → HTML (text/html) — для страниц с формами.
|
|
||||||
# иначе → text/plain — для человекочитаемого текста.
|
|
||||||
# Позволяет функциям возвращать HTML напрямую без изменений runtime-образа.
|
|
||||||
if isinstance(data, str):
|
|
||||||
body = data.encode("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"
|
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", ctype)
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# 2026-04-25 (добавлен index.html — встраивается через go:embed)
|
# 2026-03-18
|
||||||
# Dockerfile для sless-funcs-service.
|
# Dockerfile для sless-funcs-service.
|
||||||
# Многоэтапная сборка: Go → alpine (минимальный образ).
|
# Многоэтапная сборка: Go → alpine (минимальный образ).
|
||||||
|
# Сервис не нуждается во внешних зависимостях — только stdlib.
|
||||||
|
|
||||||
FROM golang:1.23-alpine AS builder
|
FROM golang:1.23-alpine AS builder
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY go.mod main.go index.html ./
|
COPY go.mod main.go ./
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o funcs-service .
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM alpine:3.20
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
# 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,492 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>sless console</title>
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
||||||
<style>
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
||||||
background: #0d1117;
|
|
||||||
color: #e6edf3;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #161b22;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #30363d;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
|
||||||
padding: 14px 24px;
|
|
||||||
border-bottom: 1px solid #30363d;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: #161b22;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #58a6ff;
|
|
||||||
letter-spacing: -0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-slash {
|
|
||||||
color: #484f58;
|
|
||||||
margin: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ns-badge {
|
|
||||||
background: #1c2128;
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
padding: 3px 12px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn {
|
|
||||||
margin-left: auto;
|
|
||||||
background: #21262d;
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
color: #c9d1d9;
|
|
||||||
padding: 6px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.refresh-btn:hover {
|
|
||||||
background: #30363d;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 24px;
|
|
||||||
max-width: 960px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
color: #8b949e;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-card {
|
|
||||||
background: #161b22;
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-card:hover {
|
|
||||||
border-color: #484f58;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
row-gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-header:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
color: #e6edf3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-runtime {
|
|
||||||
background: #21262d;
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-Ready {
|
|
||||||
background: #1a4731;
|
|
||||||
border: 1px solid #1f6132;
|
|
||||||
color: #3fb950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-Building {
|
|
||||||
background: #382a0a;
|
|
||||||
border: 1px solid #5a4212;
|
|
||||||
color: #d29922;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-Pending {
|
|
||||||
background: #21262d;
|
|
||||||
border: 1px solid #30363d;
|
|
||||||
color: #8b949e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-Failed {
|
|
||||||
background: #3d1a1a;
|
|
||||||
border: 1px solid #6b1a1a;
|
|
||||||
color: #f85149;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-url {
|
|
||||||
font-size: 0.76rem;
|
|
||||||
color: #58a6ff;
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: 280px;
|
|
||||||
flex-shrink: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-url:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-cron {
|
|
||||||
font-size: 0.76rem;
|
|
||||||
color: #8b949e;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-msg {
|
|
||||||
font-size: 0.73rem;
|
|
||||||
color: #f85149;
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1 0 0;
|
|
||||||
min-width: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.5;
|
|
||||||
transition: opacity 0.1s, background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn:disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn.state-on {
|
|
||||||
background: #21262d;
|
|
||||||
border-color: #1f6132;
|
|
||||||
color: #3fb950;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn.state-on:hover:not(:disabled) {
|
|
||||||
background: #1a4731;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn.state-off {
|
|
||||||
background: #21262d;
|
|
||||||
border-color: #6b1a1a;
|
|
||||||
color: #f85149;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger-btn.state-off:hover:not(:disabled) {
|
|
||||||
background: #3d1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand-icon {
|
|
||||||
color: #8b949e;
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
transition: transform 0.18s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fn-body {
|
|
||||||
border-top: 1px solid #30363d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.src-msg {
|
|
||||||
padding: 14px 16px;
|
|
||||||
color: #8b949e;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-block {
|
|
||||||
border-bottom: 1px solid #21262d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-block:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-header {
|
|
||||||
padding: 6px 16px;
|
|
||||||
background: #0d1117;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 0.76rem;
|
|
||||||
color: #8b949e;
|
|
||||||
border-bottom: 1px solid #21262d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-block pre {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-block pre code.hljs {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
font-size: 0.78rem !important;
|
|
||||||
padding: 14px 16px !important;
|
|
||||||
line-height: 1.5 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 64px 24px;
|
|
||||||
color: #484f58;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<header>
|
|
||||||
<span class="logo">sless</span>
|
|
||||||
<span class="logo-slash">/</span>
|
|
||||||
<span class="ns-badge" id="ns-label">…</span>
|
|
||||||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
|
||||||
</header>
|
|
||||||
<main>
|
|
||||||
<p class="summary" id="summary"></p>
|
|
||||||
<div id="fn-list"></div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
var raw = document.getElementById('fndata').textContent;
|
|
||||||
var data = JSON.parse(raw);
|
|
||||||
var ns = data.namespace;
|
|
||||||
var extURL = data.externalURL || '';
|
|
||||||
var fns = data.functions || [];
|
|
||||||
var byFn = data.triggersByFn || {};
|
|
||||||
|
|
||||||
document.getElementById('ns-label').textContent = ns;
|
|
||||||
document.getElementById('summary').textContent = 'Функций: ' + fns.length;
|
|
||||||
|
|
||||||
var list = document.getElementById('fn-list');
|
|
||||||
if (fns.length === 0) {
|
|
||||||
var empty = $el('div', 'empty-state');
|
|
||||||
empty.textContent = 'Нет функций в этом namespace';
|
|
||||||
list.appendChild(empty);
|
|
||||||
}
|
|
||||||
for (var i = 0; i < fns.length; i++) {
|
|
||||||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCard(fn, triggers) {
|
|
||||||
var card = $el('div', 'fn-card');
|
|
||||||
var hdr = $el('div', 'fn-header');
|
|
||||||
var body = $el('div', 'fn-body');
|
|
||||||
body.style.display = 'none';
|
|
||||||
|
|
||||||
$txt(hdr, 'span', 'fn-name', fn.name);
|
|
||||||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
|
||||||
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase);
|
|
||||||
|
|
||||||
for (var t = 0; t < triggers.length; t++) {
|
|
||||||
var tr = triggers[t];
|
|
||||||
if (tr.type === 'http') {
|
|
||||||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
|
||||||
if (url) {
|
|
||||||
var a = document.createElement('a');
|
|
||||||
a.className = 'fn-url';
|
|
||||||
a.href = url;
|
|
||||||
a.target = '_blank';
|
|
||||||
a.rel = 'noopener noreferrer';
|
|
||||||
a.textContent = url;
|
|
||||||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
||||||
hdr.appendChild(a);
|
|
||||||
}
|
|
||||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
|
||||||
} else if (tr.type === 'cron') {
|
|
||||||
$txt(hdr, 'span', 'fn-cron', 'cron: ' + tr.schedule);
|
|
||||||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
|
||||||
hdr.appendChild($el('span', 'spacer'));
|
|
||||||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
|
||||||
|
|
||||||
var loaded = false;
|
|
||||||
hdr.addEventListener('click', function () {
|
|
||||||
if (body.style.display === 'none') {
|
|
||||||
body.style.display = 'block';
|
|
||||||
icon.style.transform = 'rotate(90deg)';
|
|
||||||
if (!loaded) {
|
|
||||||
loaded = true;
|
|
||||||
loadSource(body, ns, fn.name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body.style.display = 'none';
|
|
||||||
icon.style.transform = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
card.appendChild(hdr);
|
|
||||||
card.appendChild(body);
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeTriggerBtn(tr, ns) {
|
|
||||||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
|
||||||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
|
||||||
btn.title = tr.name;
|
|
||||||
btn.addEventListener('click', function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
btn.disabled = true;
|
|
||||||
var newVal = !tr.enabled;
|
|
||||||
fetch('/funcs/' + ns + '/triggers/' + tr.name, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: newVal })
|
|
||||||
}).then(function (r) {
|
|
||||||
if (r.ok) {
|
|
||||||
tr.enabled = newVal;
|
|
||||||
btn.className = 'trigger-btn ' + (newVal ? 'state-on' : 'state-off');
|
|
||||||
btn.textContent = newVal ? '■ стоп' : '▶ запуск';
|
|
||||||
} else {
|
|
||||||
return r.text().then(function (t) { alert('Ошибка: ' + t); });
|
|
||||||
}
|
|
||||||
}).catch(function (err) {
|
|
||||||
alert('Сетевая ошибка: ' + err);
|
|
||||||
}).finally(function () {
|
|
||||||
btn.disabled = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return btn;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSource(body, ns, fnName) {
|
|
||||||
var msg = $el('div', 'src-msg');
|
|
||||||
msg.textContent = 'Загрузка кода…';
|
|
||||||
body.appendChild(msg);
|
|
||||||
|
|
||||||
fetch('/funcs/' + ns + '/source/' + fnName)
|
|
||||||
.then(function (r) {
|
|
||||||
body.removeChild(msg);
|
|
||||||
if (!r.ok) {
|
|
||||||
var e = $el('div', 'src-msg');
|
|
||||||
e.textContent = 'Ошибка ' + r.status;
|
|
||||||
body.appendChild(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return r.json().then(function (files) {
|
|
||||||
var shown = files.filter(function (f) { return !f.binary; });
|
|
||||||
if (shown.length === 0) {
|
|
||||||
var e = $el('div', 'src-msg');
|
|
||||||
e.textContent = 'Код не загружен';
|
|
||||||
body.appendChild(e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (var i = 0; i < shown.length; i++) {
|
|
||||||
var f = shown[i];
|
|
||||||
var block = $el('div', 'file-block');
|
|
||||||
var fhdr = $el('div', 'file-header');
|
|
||||||
fhdr.textContent = f.name;
|
|
||||||
var pre = document.createElement('pre');
|
|
||||||
var code = document.createElement('code');
|
|
||||||
code.className = 'language-' + detectLang(f.name);
|
|
||||||
code.textContent = f.content;
|
|
||||||
pre.appendChild(code);
|
|
||||||
block.appendChild(fhdr);
|
|
||||||
block.appendChild(pre);
|
|
||||||
body.appendChild(block);
|
|
||||||
}
|
|
||||||
hljs.highlightAll();
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(function (err) {
|
|
||||||
body.innerHTML = '';
|
|
||||||
var e = $el('div', 'src-msg');
|
|
||||||
e.textContent = 'Ошибка: ' + err;
|
|
||||||
body.appendChild(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectLang(name) {
|
|
||||||
var ext = name.split('.').pop().toLowerCase();
|
|
||||||
var m = {
|
|
||||||
py: 'python', js: 'javascript', ts: 'typescript', go: 'go',
|
|
||||||
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml',
|
|
||||||
json: 'json', md: 'markdown', txt: 'plaintext', toml: 'toml',
|
|
||||||
rs: 'rust', rb: 'ruby', java: 'java', cpp: 'cpp', c: 'c',
|
|
||||||
cs: 'csharp', php: 'php', html: 'html', css: 'css'
|
|
||||||
};
|
|
||||||
return m[ext] || 'plaintext';
|
|
||||||
}
|
|
||||||
|
|
||||||
function $el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
|
||||||
function $txt(p, tag, cls, text) { var e = $el(tag, cls); e.textContent = text; p.appendChild(e); return e; }
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
+132
-331
@@ -1,29 +1,25 @@
|
|||||||
// Изменено: 2026-04-25 (добавлена HTML web-консоль, proxy endpoints для source/triggers)
|
// 2026-03-18
|
||||||
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
// main.go — глобальный HTTP сервис листинга функций пользователя.
|
||||||
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
// Развёрнут ОДИН РАЗ в namespace sless; работает для ВСЕХ пользователей.
|
||||||
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
// Не связан с terraform — деплоится манифестом deployments/k8s/funcs-service.yaml.
|
||||||
//
|
//
|
||||||
// Маршруты:
|
// Логика:
|
||||||
// GET /funcs — usage hint (без токена)
|
// 1. Принимает Authorization: Bearer {jwt} от пользователя
|
||||||
// GET /funcs?token=<jwt> — листинг через JWT токен
|
// 2. Извлекает sub из JWT (без проверки подписи — trusted perimeter за Ingress)
|
||||||
// GET /funcs/<namespace> — листинг (Accept: text/html → HTML, иначе plain text)
|
// 3. Вычисляет namespace = SHA256(sub)[:8] hex (та же логика что в операторе)
|
||||||
// GET /funcs/<namespace>/source/<fn> — прокси к оператору: файлы исходного кода (JSON)
|
// 4. Вызывает внутренний API оператора: GET /v1/namespaces/{ns}/functions + /triggers
|
||||||
// PATCH /funcs/<namespace>/triggers/<n> — прокси к оператору: enable/disable триггера
|
// 5. Возвращает plain text — человекочитаемый список
|
||||||
// GET /health — liveness/readiness probe
|
|
||||||
//
|
//
|
||||||
// Env vars:
|
// Env vars:
|
||||||
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
// SLESS_OPERATOR_URL — URL оператора внутри кластера (default: http://sless-operator.sless.svc.cluster.local:9090)
|
||||||
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
|
// SLESS_EXTERNAL_URL — публичный базовый URL для корректных ссылок на функции
|
||||||
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
|
// SLESS_EXCLUDE — comma-separated список имён функций, скрытых из листинга
|
||||||
// SLESS_SERVICE_TOKEN — токен сервиса для запросов к оператору (для /funcs/<namespace> без токена юзера)
|
|
||||||
// PORT — порт сервера (default: 8090)
|
// PORT — порт сервера (default: 8090)
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
_ "embed"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -31,14 +27,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed index.html
|
|
||||||
var htmlPageTemplate string
|
|
||||||
|
|
||||||
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
|
// fnResponse — ответ /v1/namespaces/{ns}/functions (подмножество полей оператора)
|
||||||
type fnResponse struct {
|
type fnResponse struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -60,22 +52,11 @@ type trResponse struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageData — структура данных для HTML-шаблона web-консоли.
|
|
||||||
type pageData struct {
|
|
||||||
Namespace string `json:"namespace"`
|
|
||||||
ExternalURL string `json:"externalURL"`
|
|
||||||
Functions []fnResponse `json:"functions"`
|
|
||||||
TriggersByFn map[string][]trResponse `json:"triggersByFn"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// nsRegex — базовая валидация kubernetes namespace (ловеркейс + hyphens).
|
|
||||||
// Защита от path traversal при подстановке в URL запросов к оператору.
|
|
||||||
var nsRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$`)
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
|
operatorURL := strings.TrimRight(env("SLESS_OPERATOR_URL", "http://sless-operator.sless.svc.cluster.local:9090"), "/")
|
||||||
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
|
externalURL := strings.TrimRight(env("SLESS_EXTERNAL_URL", ""), "/")
|
||||||
port := env("PORT", "8090")
|
port := env("PORT", "8090")
|
||||||
|
// serviceToken — токен сервиса для запросов к оператору по /funcs/<namespace> (без токена пользователя)
|
||||||
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
|
serviceToken := os.Getenv("SLESS_SERVICE_TOKEN")
|
||||||
|
|
||||||
exclude := map[string]bool{}
|
exclude := map[string]bool{}
|
||||||
@@ -85,333 +66,153 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// /funcs (точное совпадение) — namespace из JWT токена (?token= или Authorization header)
|
http.HandleFunc("/funcs", handler(operatorURL, externalURL, serviceToken, exclude))
|
||||||
http.HandleFunc("/funcs", handleFuncsToken(operatorURL, externalURL, exclude))
|
// /health — для liveness/readiness probe без Bearer токена
|
||||||
// /funcs/ — namespace в URL пути + sub-paths (source/triggers proxy)
|
|
||||||
http.HandleFunc("/funcs/", handleFuncsNS(operatorURL, externalURL, serviceToken, exclude))
|
|
||||||
// /health — для liveness/readiness probe (без auth)
|
|
||||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
fmt.Fprintln(w, "ok")
|
fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
// /funcs/<namespace> — без токена, namespace виден в URL всех функций
|
||||||
|
http.HandleFunc("/funcs/", handler(operatorURL, externalURL, serviceToken, exclude))
|
||||||
|
|
||||||
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
|
log.Printf("sless-funcs-service listening on :%s (operator: %s)", port, operatorURL)
|
||||||
log.Fatal(http.ListenAndServe(":"+port, nil))
|
log.Fatal(http.ListenAndServe(":"+port, nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleFuncsToken обрабатывает GET /funcs с JWT токеном (?token= или Authorization header).
|
func handler(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
|
||||||
func handleFuncsToken(operatorURL, externalURL string, exclude map[string]bool) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := r.URL.Query().Get("token")
|
var namespace, authHeader string
|
||||||
if token == "" {
|
|
||||||
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
||||||
}
|
|
||||||
if token == "" {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
fmt.Fprintln(w, "Укажите namespace в URL или передайте токен:")
|
|
||||||
fmt.Fprintln(w, "")
|
|
||||||
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
|
|
||||||
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sub, err := subFromJWT(token)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetchAndRender(w, r, operatorURL, externalURL, "Bearer "+token, namespaceFromSub(sub), exclude)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleFuncsNS обрабатывает все запросы /funcs/{ns}[/sub/path].
|
// Вариант 1: /funcs/<namespace> — namespace прямо в URL, токен не нужен.
|
||||||
//
|
// Namespace виден в URL каждой функции — не является секретом.
|
||||||
// Маршруты:
|
if strings.HasPrefix(r.URL.Path, "/funcs/") {
|
||||||
//
|
namespace = strings.TrimPrefix(r.URL.Path, "/funcs/")
|
||||||
// GET /funcs/{ns} — листинг (HTML если Accept: text/html, иначе plain text)
|
namespace = strings.Trim(namespace, "/")
|
||||||
// GET /funcs/{ns}/source/{fn} — прокси: файлы исходного кода функции
|
if namespace == "" {
|
||||||
// PATCH /funcs/{ns}/triggers/{name} — прокси: enable/disable триггер
|
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
|
||||||
func handleFuncsNS(operatorURL, externalURL, serviceToken string, exclude map[string]bool) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rest := strings.TrimPrefix(r.URL.Path, "/funcs/")
|
|
||||||
rest = strings.Trim(rest, "/")
|
|
||||||
if rest == "" {
|
|
||||||
http.Error(w, "usage: /funcs/<namespace>\n", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitN до 3 частей: [ns], [ns, subtype] или [ns, subtype, resourceName]
|
|
||||||
parts := strings.SplitN(rest, "/", 3)
|
|
||||||
ns := parts[0]
|
|
||||||
|
|
||||||
if !nsRegex.MatchString(ns) {
|
|
||||||
http.Error(w, "invalid namespace\n", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(parts) == 3 {
|
|
||||||
switch parts[1] {
|
|
||||||
case "source":
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxySourceGet(w, r, operatorURL, serviceToken, ns, parts[2])
|
|
||||||
return
|
|
||||||
case "triggers":
|
|
||||||
if r.Method != http.MethodPatch {
|
|
||||||
http.Error(w, "method not allowed\n", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxyTriggerPatch(w, r, operatorURL, serviceToken, ns, parts[2])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
// Используем serviceToken сервиса, если задан
|
||||||
|
if serviceToken != "" {
|
||||||
if len(parts) > 1 {
|
authHeader = "Bearer " + serviceToken
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /funcs/{ns} — листинг с авторизацией через serviceToken
|
|
||||||
authHeader := ""
|
|
||||||
if serviceToken != "" {
|
|
||||||
authHeader = "Bearer " + serviceToken
|
|
||||||
}
|
|
||||||
fetchAndRender(w, r, operatorURL, externalURL, authHeader, ns, exclude)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchAndRender загружает данные из оператора и отдаёт ответ: HTML (браузер) или plain text (curl).
|
|
||||||
func fetchAndRender(w http.ResponseWriter, r *http.Request, operatorURL, externalURL, authHeader, ns string, exclude map[string]bool) {
|
|
||||||
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+ns+"/functions", authHeader)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+ns+"/triggers", authHeader)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if acceptsHTML(r) {
|
|
||||||
serveHTML(w, ns, externalURL, fns, trs, exclude)
|
|
||||||
} else {
|
|
||||||
servePlainText(w, ns, externalURL, fns, trs, exclude)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// acceptsHTML возвращает true если клиент предпочитает HTML (браузер).
|
|
||||||
// curl без -H "Accept: text/html" получит plain text — поведение совместимо с v0.1.x.
|
|
||||||
func acceptsHTML(r *http.Request) bool {
|
|
||||||
return strings.Contains(r.Header.Get("Accept"), "text/html")
|
|
||||||
}
|
|
||||||
|
|
||||||
// serveHTML рендерит web-консоль.
|
|
||||||
// Данные встраиваются как JSON в <script type="application/json"> — изолировано от HTML-контекста.
|
|
||||||
// </script> в данных экранируется как <\/script> (валидный JSON escape).
|
|
||||||
func serveHTML(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
|
|
||||||
trigIdx := buildTriggerIndex(trs)
|
|
||||||
filtered := filterAndSort(fns, trigIdx, exclude)
|
|
||||||
|
|
||||||
pd := pageData{
|
|
||||||
Namespace: ns,
|
|
||||||
ExternalURL: externalURL,
|
|
||||||
Functions: filtered,
|
|
||||||
TriggersByFn: trigIdx,
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, _ := json.Marshal(pd)
|
|
||||||
// Предотвращаем преждевременное закрытие тега <script type="application/json">
|
|
||||||
safeJSON := strings.ReplaceAll(string(raw), "</", `<\/`)
|
|
||||||
|
|
||||||
page := strings.ReplaceAll(htmlPageTemplate, "__PAGE_DATA__", safeJSON)
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
fmt.Fprint(w, page)
|
|
||||||
}
|
|
||||||
|
|
||||||
// servePlainText рендерит human-readable листинг для терминала (curl).
|
|
||||||
func servePlainText(w http.ResponseWriter, ns, externalURL string, fns []fnResponse, trs []trResponse, exclude map[string]bool) {
|
|
||||||
trigIdx := buildTriggerIndex(trs)
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
fn fnResponse
|
|
||||||
httpT []trResponse
|
|
||||||
cronT []trResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
var items []entry
|
|
||||||
for _, fn := range fns {
|
|
||||||
if exclude[fn.Name] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var httpT, cronT []trResponse
|
|
||||||
for _, tr := range trigIdx[fn.Name] {
|
|
||||||
switch tr.Type {
|
|
||||||
case "http":
|
|
||||||
httpT = append(httpT, tr)
|
|
||||||
case "cron":
|
|
||||||
cronT = append(cronT, tr)
|
|
||||||
}
|
}
|
||||||
}
|
// authHeader может остаться пустым — оператор ответит 401 если не настроен
|
||||||
items = append(items, entry{fn, httpT, cronT})
|
|
||||||
}
|
|
||||||
sort.Slice(items, func(i, j int) bool {
|
|
||||||
ai := isActive(items[i].fn.Name, trigIdx)
|
|
||||||
aj := isActive(items[j].fn.Name, trigIdx)
|
|
||||||
if ai != aj {
|
|
||||||
return ai
|
|
||||||
}
|
|
||||||
return items[i].fn.Name < items[j].fn.Name
|
|
||||||
})
|
|
||||||
|
|
||||||
sep := strings.Repeat("─", 52)
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, it := range items {
|
|
||||||
fn := it.fn
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
|
|
||||||
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
|
|
||||||
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
|
|
||||||
if isActive(fn.Name, trigIdx) {
|
|
||||||
sb.WriteString(" active: да\n")
|
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString(" active: нет\n")
|
// Вариант 2: ?token=<jwt> — токен как query param (можно bookmarkнуть URL)
|
||||||
}
|
token := r.URL.Query().Get("token")
|
||||||
if len(it.httpT) > 0 {
|
// Вариант 3: Authorization: Bearer <jwt> — стандартный заголовок
|
||||||
url := it.httpT[0].URL
|
if token == "" {
|
||||||
if externalURL != "" {
|
token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
url = externalURL + "/fn/" + ns + "/" + fn.Name
|
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
|
if token == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
fmt.Fprintf(w, "Укажите namespace в URL или передайте токен:\n\n")
|
||||||
|
fmt.Fprintf(w, " %s/funcs/<namespace>\n", externalURL)
|
||||||
|
fmt.Fprintf(w, " %s/funcs?token=<jwt>\n", externalURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sub, err := subFromJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid token: %s\n", err), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace = namespaceFromSub(sub)
|
||||||
|
authHeader = "Bearer " + token
|
||||||
}
|
}
|
||||||
if len(it.cronT) > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
|
|
||||||
}
|
|
||||||
if fn.CreatedAt != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
|
|
||||||
}
|
|
||||||
if fn.LastBuiltAt != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
|
|
||||||
}
|
|
||||||
if fn.Message != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", ns, len(items)))
|
|
||||||
sb.WriteString(sep + "\n")
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
fns, err := apiGet[[]fnResponse](operatorURL+"/v1/namespaces/"+namespace+"/functions", authHeader)
|
||||||
fmt.Fprint(w, sb.String())
|
if err != nil {
|
||||||
}
|
http.Error(w, fmt.Sprintf("operator error (functions): %s\n", err), http.StatusBadGateway)
|
||||||
|
return
|
||||||
// proxySourceGet проксирует GET /funcs/{ns}/source/{fn} → оператор GET /v1/.../source.
|
|
||||||
// Авторизация — сервисный токен (хранится на сервере, не открывается клиенту).
|
|
||||||
func proxySourceGet(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, funcName string) {
|
|
||||||
if !isValidK8sName(funcName) {
|
|
||||||
http.Error(w, "invalid function name\n", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target := operatorURL + "/v1/namespaces/" + ns + "/functions/" + funcName + "/source"
|
|
||||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error\n", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if serviceToken != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// proxyTriggerPatch проксирует PATCH /funcs/{ns}/triggers/{name} → оператор.
|
|
||||||
// Принимает только поле "enabled" — защита от произвольных изменений с сервисным токеном.
|
|
||||||
func proxyTriggerPatch(w http.ResponseWriter, r *http.Request, operatorURL, serviceToken, ns, triggerName string) {
|
|
||||||
if !isValidK8sName(triggerName) {
|
|
||||||
http.Error(w, "invalid trigger name\n", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Читаем только поле enabled — остальные поля отбрасываем.
|
|
||||||
var body struct {
|
|
||||||
Enabled *bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Enabled == nil {
|
|
||||||
http.Error(w, `{"error":"required: {\"enabled\": true/false}"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reqBody, _ := json.Marshal(map[string]bool{"enabled": *body.Enabled})
|
|
||||||
target := operatorURL + "/v1/namespaces/" + ns + "/triggers/" + triggerName
|
|
||||||
opReq, err := http.NewRequestWithContext(r.Context(), http.MethodPatch, target, bytes.NewReader(reqBody))
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "internal error\n", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
opReq.Header.Set("Content-Type", "application/json")
|
|
||||||
if serviceToken != "" {
|
|
||||||
opReq.Header.Set("Authorization", "Bearer "+serviceToken)
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(opReq)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("operator error: %s\n", err), http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
io.Copy(w, resp.Body) //nolint:errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildTriggerIndex строит индекс триггеров по имени функции (FunctionRef).
|
|
||||||
func buildTriggerIndex(trs []trResponse) map[string][]trResponse {
|
|
||||||
idx := map[string][]trResponse{}
|
|
||||||
for _, tr := range trs {
|
|
||||||
idx[tr.FunctionRef] = append(idx[tr.FunctionRef], tr)
|
|
||||||
}
|
|
||||||
return idx
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterAndSort фильтрует функции (exclude) и сортирует: активные вверх, затем по имени.
|
|
||||||
func filterAndSort(fns []fnResponse, trigIdx map[string][]trResponse, exclude map[string]bool) []fnResponse {
|
|
||||||
result := make([]fnResponse, 0, len(fns))
|
|
||||||
for _, fn := range fns {
|
|
||||||
if !exclude[fn.Name] {
|
|
||||||
result = append(result, fn)
|
|
||||||
}
|
}
|
||||||
}
|
trs, err := apiGet[[]trResponse](operatorURL+"/v1/namespaces/"+namespace+"/triggers", authHeader)
|
||||||
sort.Slice(result, func(i, j int) bool {
|
if err != nil {
|
||||||
ai := isActive(result[i].Name, trigIdx)
|
http.Error(w, fmt.Sprintf("operator error (triggers): %s\n", err), http.StatusBadGateway)
|
||||||
aj := isActive(result[j].Name, trigIdx)
|
return
|
||||||
if ai != aj {
|
|
||||||
return ai
|
|
||||||
}
|
}
|
||||||
return result[i].Name < result[j].Name
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// isActive возвращает true если хотя бы один триггер функции включён и активен.
|
// Индекс триггеров по имени функции
|
||||||
func isActive(funcName string, trigIdx map[string][]trResponse) bool {
|
trigIdx := map[string][]trResponse{}
|
||||||
for _, tr := range trigIdx[funcName] {
|
for _, tr := range trs {
|
||||||
if tr.Enabled && tr.Active {
|
trigIdx[tr.FunctionRef] = append(trigIdx[tr.FunctionRef], tr)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidK8sName базовая проверка имени k8s-ресурса: нет path traversal символов.
|
// Фильтрация и сортировка: активные вверх, потом по имени
|
||||||
func isValidK8sName(s string) bool {
|
type entry struct {
|
||||||
return s != "" && !strings.Contains(s, "/") && !strings.Contains(s, "..") && len(s) <= 253
|
fn fnResponse
|
||||||
|
httpT []trResponse
|
||||||
|
cronT []trResponse
|
||||||
|
isActive bool
|
||||||
|
}
|
||||||
|
var items []entry
|
||||||
|
for _, fn := range fns {
|
||||||
|
if exclude[fn.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var httpT, cronT []trResponse
|
||||||
|
isActive := false
|
||||||
|
for _, tr := range trigIdx[fn.Name] {
|
||||||
|
switch tr.Type {
|
||||||
|
case "http":
|
||||||
|
httpT = append(httpT, tr)
|
||||||
|
case "cron":
|
||||||
|
cronT = append(cronT, tr)
|
||||||
|
}
|
||||||
|
if tr.Enabled && tr.Active {
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, entry{fn, httpT, cronT, isActive})
|
||||||
|
}
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
if items[i].isActive != items[j].isActive {
|
||||||
|
return items[i].isActive
|
||||||
|
}
|
||||||
|
return items[i].fn.Name < items[j].fn.Name
|
||||||
|
})
|
||||||
|
|
||||||
|
sep := strings.Repeat("─", 52)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, it := range items {
|
||||||
|
fn := it.fn
|
||||||
|
sb.WriteString(sep + "\n")
|
||||||
|
sb.WriteString(" " + buildComment(fn, it.httpT, it.cronT) + "\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" name: %s\n", fn.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf(" runtime: %s\n", fn.Runtime))
|
||||||
|
sb.WriteString(fmt.Sprintf(" phase: %s\n", fn.Phase))
|
||||||
|
if it.isActive {
|
||||||
|
sb.WriteString(" active: да\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString(" active: нет\n")
|
||||||
|
}
|
||||||
|
if len(it.httpT) > 0 {
|
||||||
|
url := it.httpT[0].URL
|
||||||
|
if externalURL != "" {
|
||||||
|
url = externalURL + "/fn/" + namespace + "/" + fn.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" url: %s\n", url))
|
||||||
|
}
|
||||||
|
if len(it.cronT) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" cron: %s\n", it.cronT[0].Schedule))
|
||||||
|
}
|
||||||
|
if fn.CreatedAt != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" created: %s\n", fn.CreatedAt))
|
||||||
|
}
|
||||||
|
if fn.LastBuiltAt != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" built: %s\n", fn.LastBuiltAt))
|
||||||
|
}
|
||||||
|
if fn.Message != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" message: %s\n", fn.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(sep + "\n")
|
||||||
|
sb.WriteString(fmt.Sprintf(" namespace: %s | total: %d\n", namespace, len(items)))
|
||||||
|
sb.WriteString(sep + "\n")
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
fmt.Fprint(w, sb.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
|
func buildComment(fn fnResponse, httpT, cronT []trResponse) string {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 2026-03-17 12:20
|
// 2026-03-11
|
||||||
// client.go — HTTP-клиент для REST API sless оператора.
|
// client.go — HTTP-клиент для REST API sless оператора.
|
||||||
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
|
// Изолирован от terraform-plugin-framework — зависит только от stdlib и net/http.
|
||||||
// Все методы принимают ctx для правильной работы с таймаутами terraform.
|
// Все методы принимают ctx для правильной работы с таймаутами terraform.
|
||||||
@@ -22,7 +22,6 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -45,9 +44,6 @@ type Client struct {
|
|||||||
Namespace string
|
Namespace string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrJobAlreadyExists возвращается при попытке создать FunctionJob с уже существующим именем.
|
|
||||||
var ErrJobAlreadyExists = errors.New("job already exists")
|
|
||||||
|
|
||||||
// New создаёт клиент.
|
// New создаёт клиент.
|
||||||
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
|
// - endpoint — базовый URL оператора (без trailing slash), например "https://sless-api.kube5s.ru"
|
||||||
// - token — Bearer JWT-токен облака
|
// - token — Bearer JWT-токен облака
|
||||||
@@ -460,9 +456,6 @@ func (c *Client) CreateJob(ctx context.Context, ns string, req JobRequest) (*Job
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusCreated {
|
if resp.StatusCode != http.StatusCreated {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
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)
|
return nil, fmt.Errorf("create job: status %d: %s", resp.StatusCode, body)
|
||||||
}
|
}
|
||||||
var j JobResponse
|
var j JobResponse
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 2026-03-17 12:20
|
// 2026-03-08
|
||||||
// job_resource.go — Terraform ресурс sless_job.
|
// job_resource.go — Terraform ресурс sless_job.
|
||||||
//
|
//
|
||||||
// Lifecycle:
|
// Lifecycle:
|
||||||
@@ -17,7 +17,6 @@ package resources
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -164,28 +163,15 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
|||||||
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
|
// run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job.
|
||||||
// Пользователь может поменять run_id > 0 позже чтобы запустить.
|
// Пользователь может поменять run_id > 0 позже чтобы запустить.
|
||||||
runID := plan.RunID.ValueInt64()
|
runID := plan.RunID.ValueInt64()
|
||||||
createdJob, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
_, err := r.client.CreateJob(ctx, ns, client.JobRequest{
|
||||||
Name: plan.Name.ValueString(),
|
Name: plan.Name.ValueString(),
|
||||||
FunctionRef: plan.Function.ValueString(),
|
FunctionRef: plan.Function.ValueString(),
|
||||||
EventJSON: eventJSON,
|
EventJSON: eventJSON,
|
||||||
RunID: runID,
|
RunID: runID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, client.ErrJobAlreadyExists) {
|
resp.Diagnostics.AddError("create job", err.Error())
|
||||||
existingJob, getErr := r.client.GetJob(ctx, ns, plan.Name.ValueString())
|
return
|
||||||
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 сразу
|
// Если RunID=0 — не ждём завершения, пишем state сразу
|
||||||
@@ -201,10 +187,10 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re
|
|||||||
EventJSON: plan.EventJSON,
|
EventJSON: plan.EventJSON,
|
||||||
RunID: types.Int64Value(0),
|
RunID: types.Int64Value(0),
|
||||||
WaitTimeoutSec: waitTimeoutSec,
|
WaitTimeoutSec: waitTimeoutSec,
|
||||||
Phase: types.StringValue(createdJob.Phase),
|
Phase: types.StringValue("Skipped"),
|
||||||
StartTime: types.StringValue(createdJob.StartTime),
|
StartTime: types.StringValue(""),
|
||||||
CompletionTime: types.StringValue(createdJob.CompletionTime),
|
CompletionTime: types.StringValue(""),
|
||||||
Message: types.StringValue(createdJob.Message),
|
Message: types.StringValue("run_id=0: set run_id>0 to execute"),
|
||||||
})...)
|
})...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user