Author SHA1 Message Date
Repinoid 4a03a7c6db chore: update ignore rules 2026-10-27 14:19:59 +03:00
Repinoid a66a444433 chore: update ignore rules 2026-10-27 14:09:07 +03:00
“Naeel” a5794d83e0 docs: clarify separate providers decision; fix invocations 501 stub 2026-03-11 07:24:34 +04:00
“Naeel” 008b93d07a fix: stage0 quick fixes (operator v0.1.19)
- TriggerReconciler: RequeueAfter 15s когда Function не Ready
  (ранее зависал без повторного reconcile)
- FunctionJobReconciler: RequeueAfter 15s когда Function не Ready
- UpdateFunction: добавлена валидация runtime/entrypoint/memory_mb
  (ранее мог затереть spec нулями при частичном обновлении)
- CronJob: curlimages/curl:latest → curlimages/curl:8.5.0 (pin version)
- Config: удалён FunctionNamespacePrefix (мёртвое поле, нигде не использовалось)
- Invocations endpoint: возвращает 501 вместо пустого списка
  (SaveInvocation нигде не вызывается — честный ответ клиенту)
- Собран образ naeel/sless-operator:v0.1.19
2026-03-10 17:36:48 +04:00
“Naeel” 50b26164f5 docs: добавлен анализ GPT-5.4 и Opus 4.6
- agent-handoff-2026-03-10.md — GPT-5.4 code review (lifecycle issues, invocation history gap)
- opus-pragmatic-review-2026-03-10.md — Opus прагматичный review для небольшого провайдера
- Opus: gVisor/LLM validation — overkill для MVP, фокус на быстрые фиксы + ResourceQuota/NetworkPolicy
- Обновлён progress.md с новыми документами
- .gitignore — добавлен test.token
2026-03-10 08:56:59 +04:00
“Naeel” 2c7fe99816 docs: Claude Sonnet review of GPT-5.4 analysis + security roadmap
- Added sonnet-review-of-gpt-analysis.md
- GPT-5.4 правильно выявил lifecycle issues (event model, invocation history)
- Но пропустил security roadmap (gVisor, NetworkPolicy, LLM validation)
- Дополнен production security roadmap для следующего агента
- Updated progress.md — зафиксированы оба анализа
2026-03-10 08:36:46 +04:00
“Naeel” 2e22072934 docs: update progress.md, errors/log.md; add gitignore for dist/ and tfstate backups
- doc/progress.md: обновлена версия до operator v0.1.18 / provider v0.1.11,
  добавлен блок source_dir + fix destroy cleanup (2026-03-09),
  уточнены комментарии к контроллерам и trigger_resource
- doc/errors/log.md: добавлены две записи — source_dir/hashicorp/archive,
  destroy route cleanup bug (три причины + решения)
- .gitignore: добавлены examples/*/dist/ и terraform.tfstate.*.backup
- examples/: удалены dist/ zip и tfstate.*.backup из трекинга
2026-03-09 20:29:27 +04:00
“Naeel” 4142cdba43 docs: clarify sless_function description 2026-03-09 20:24:59 +04:00
“Naeel” bb54d2a681 docs: remove informal tone from README 2026-03-09 20:24:23 +04:00
“Naeel” 4d3f42b6af docs: add project description and resource types to examples README 2026-03-09 20:23:31 +04:00
“Naeel” fec2a427e7 fix: return 404 (not 502) when function Service is gone (DNS no such host)
After trigger/function destroy the Service is deleted, DNS lookup fails with
'no such host'. Previously InvokeFunction returned 502 which kept the test
script retrying for 120s. Now returns 404 — endpoint removed, test passes.

Operator: naeel/sless-operator:v0.1.18
2026-03-09 20:03:08 +04:00
“Naeel” bd346e5877 fix: restore token in provider blocks (dev-token-change-me is the real token) 2026-03-09 19:51:27 +04:00
“Naeel” fcd5020577 chore: remove hardcoded token from examples, use SLESS_API_TOKEN env 2026-03-09 19:25:52 +04:00
“Naeel” 1e592a1a52 fix: destroy route cleanup bug — delete Service+Ingress on trigger/function deletion
- controllers/trigger_controller.go: handleTriggerDeletion теперь удаляет Service
  и Ingress из sless-fn-{ns} при удалении HTTP-триггера
- controllers/function_controller.go: handleDeletion теперь удаляет Service и Ingress
  (имена совпадают с fn.Name); добавлен импорт netv1
- terraform/provider trigger_resource.go: Delete ждёт пока GetTrigger вернёт 404
  (до 90с) — провайдер не возвращает успех раньше чем cleanup завершён
- examples: версия провайдера обновлена до ~> 0.1.11

Operator: naeel/sless-operator:v0.1.17
Provider: v0.1.11

Fixes DESTROY_ROUTE_CLEANUP_BUG.md
2026-03-09 19:23:28 +04:00
“Naeel” 55e71b5cb2 chore: удалён пример pg-query 2026-03-09 17:46:52 +04:00
“Naeel” 1a37c94cb4 feat: provider v0.1.10 — source_dir (zip внутри провайдера), убран archive провайдер из всех примеров 2026-03-09 17:45:45 +04:00
“Naeel” 6c93f6b5b5 docs: examples/README.md — порядок simple-first, без токена, без pg-query 2026-03-09 15:57:29 +04:00
“Naeel” 7f651b235d feat: provider v0.1.9 — namespace hardcoded in client, removed from provider schema and all examples 2026-03-09 15:27:55 +04:00
“Naeel” e36dbd410a feat: provider v0.1.9 — namespace removed from resources, moved to provider block 2026-03-09 15:12:07 +04:00
“Naeel” 0235ba6a6d feat: operator v0.1.16 — job stdout -> status.Message (feature B)
- FunctionJobReconciler: added KubeClient field (kubernetes.Interface)
- getJobPodOutput(): reads pod logs via typed client after job succeeds
- main.go: inject kubernetes.NewForConfigOrDie into FunctionJobReconciler
- rbac.yaml: add pods/pods/log get/list/watch permissions
- examples/simple-python/: job->function chain demo (Python)
- examples/simple-node/: job->function chain demo (Node.js)

sless_job.X.message now contains the return value of the function
2026-03-09 14:50:06 +04:00
“Naeel” efaa8ce4bf feat: outputs — статус init-джобов для пользователя без kubectl 2026-03-09 14:09:55 +04:00
“Naeel” d5ffb16259 fix: operator v0.1.15 — обновлять Env при изменении функции
function_controller.go: при update deployment обновлять не только Image,
но и Env целиком. Без этого изменение entrypoint или env_vars не применялось
в уже существующем Deployment — поды продолжали стартовать со старым
SLESS_ENTRYPOINT и падали с FileNotFoundError.
2026-03-09 10:26:54 +04:00
“Naeel” c8cd5d37fe refactor: уникальные имена функций-обработчиков
handle() → run_sql()    (sql_runner.py,  entrypoint: sql_runner.run_sql)
handle() → crud()       (notes_crud.py,  entrypoint: notes_crud.crud)
handle() → list_notes() (notes_list.py,  entrypoint: notes_list.list_notes)
2026-03-09 10:14:36 +04:00
“Naeel” c672696ce6 refactor: notes-python — описательные имена файлов/ресурсов + комментарии
Python файлы:
- handler.py → sql_runner.py   (entrypoint: sql_runner.handle)
- handler.py → notes_crud.py   (entrypoint: notes_crud.handle)
- handler.py → notes_list.py   (entrypoint: notes_list.handle)

TF ресурсы переименованы:
- sless_function.notes         → sless_function.notes_crud
- sless_trigger.notes_http     → sless_trigger.notes_crud_http
- sless_job.create_table       → sless_job.notes_table_init
- sless_job.create_index       → sless_job.notes_index_init
- archive_file.notes           → archive_file.notes_crud_zip
- archive_file.sql_runner      → archive_file.sql_runner_zip
- archive_file.notes_list      → archive_file.notes_list_zip

Добавлены подробные комментарии во все .tf файлы
2026-03-09 10:10:43 +04:00
“Naeel” 8ac444dd55 fix: provider v0.1.8 — wait for trigger URL after create
trigger_resource.go: after CreateTrigger, if URL is empty (HTTP trigger),
retry GetTrigger every 2s up to 30s until controller sets status.URL.
Without this, URL in terraform state was always empty after apply.
2026-03-09 09:58:37 +04:00
“Naeel” d46d651e61 feat: notes-python CRUD example + runtime path/query forwarding
- invoke.go: forward sub-path and query string to function pods
- server.js v0.1.2: add _path, _query, _method to event
- server.py v0.1.1: add _path, _query, _method to event
- upload.go: bump runtime versions (nodejs20:v0.1.2, python3.11:v0.1.1)
- examples/notes-python: CRUD notes via sub-path routing
  - sql-runner: generic SQL executor for DDL jobs
  - notes: CRUD router (/add, /update, /delete)
  - notes-list: SELECT all notes
  - init.tf: create TABLE + INDEX on apply
2026-03-09 09:51:56 +04:00
“Naeel” 3df24e57fe refactor: pg-query — ресурсы вынесены в pg-query.tf, main.tf только провайдеры 2026-03-09 09:11:31 +04:00
“Naeel” 6fbcab8e8a refactor: pg-query — code/ исходники, dist/ zip, archive_file datasource 2026-03-09 09:07:57 +04:00
“Naeel” cb4f9beb40 refactor: hello-node — zip-файлы в dist/, исходники остаются в code/ 2026-03-09 09:03:00 +04:00
“Naeel” afe2a053d0 fix: pg-query — filesha256(handler.py) вместо filemd5(handler.zip), версия провайдера 0.1.7 2026-03-09 08:56:19 +04:00
“Naeel” d299ff655b fix: API validation + Terraform plan-time validators
API (operator v0.1.13):
- functions.go: добавлена валидация entrypoint (не пустой) и
  memory_mb (1-4096). Фиксирует БАГ-1/2/4 из негативных тестов.
- triggers.go: добавлена валидация type (только 'http'/'cron').
  Фиксирует БАГ-3 (неверное сообщение об ошибке).

Провайдер (v0.1.7):
- Добавлен пакет terraform-plugin-framework-validators v0.19.0
- function_resource: runtime OneOf, memory_mb 1-4096, timeout_sec 1-900
- trigger_resource: type OneOf(http, cron)
- job_resource: run_id AtLeast(0)
- examples/main.tf: обновлена версия до ~> 0.1.7

doc/errors/log.md: задокументированы исправления и результаты повторных тестов
2026-03-09 08:52:13 +04:00
“Naeel” fadaf96b5e fix: откат 409-fallback из клиента провайдера (provider v0.1.6 не опубликован с изменениями)
- terraform/provider/internal/client/client.go: откат 409 fallback для
  CreateFunction, CreateTrigger, CreateJob — import не нужен в данном провайдере,
  409 должен быть ошибкой
- examples/hello-node/main.tf: версия провайдера ~> 0.1.6 (опубликована
  без изменений клиента — только исходная версия кода)
- examples/hello-node/job.tf: run_id=9, numbers=[100,200,300]

Тесты пройдены: plan(no-changes), state-rm+apply, code update, run_id++, destroy+apply
2026-03-09 08:17:17 +04:00
“Naeel” cb582b3165 fix: уникальный image tag per build вместо :latest (operator v0.1.12)
- internal/builder/builder.go: ImageRef() теперь использует sha256(s3Key)[:12]
  как тег образа вместо :latest
  Пример: naeel/sless-default-hello-http:d431de61e2c9
- controllers/function_controller.go: убран хак с restartedAt аннотацией —
  больше не нужен, новый тег = kubelet всегда пул-ит новый образ
- deployments/k8s/operator.yaml: обновлён до v0.1.12
- doc/progress.md: версия обновлена

Преимущества уникального тега:
- imagePullPolicy: IfNotPresent работает корректно
- Rollback к предыдущей версии возможен (теги не перезаписываются)
- Нет race condition при параллельных сборках
2026-03-08 21:05:35 +04:00
“Naeel” ccdc4f0196 fix: rollout restart после kaniko rebuild (operator v0.1.11)
- controllers/function_controller.go: в ensureDeployment добавить аннотацию
  kubectl.kubernetes.io/restartedAt = fn.Status.LastBuiltAt при обновлении
  существующего Deployment — принудительный rolling restart после каждой сборки
- deployments/k8s/operator.yaml: образ обновлён до v0.1.11
- doc/errors/log.md: задокументирована причина и решение
- doc/decisions/log.md: добавлено правило restartedAt + :latest
- doc/progress.md: версия обновлена до v0.1.11

Причина бага: imagePullPolicy=IfNotPresent + :latest — kubelet кешировал
старый образ, pod не рестартовал после успешного kaniko build.
2026-03-08 20:55:47 +04:00
“Naeel” 86a077802e docs: обновление doc/ по итогам сессии 2026-03-08 2026-03-08 20:36:16 +04:00
“Naeel” 268191e139 fix: убрать UseStateForUnknown с image_ref, provider v0.1.5 2026-03-08 20:34:00 +04:00
“Naeel” b08f98eefb config: REGISTRY_HOST переключён с Harbor на DockerHub (naeel) 2026-03-08 20:18:59 +04:00
“Naeel” 83340e359d fix: FunctionJob зависал в Running — добавлен RequeueAfter polling 2026-03-08 12:13:15 +04:00
“Naeel” a0fc05abd6 fix: imagePullSecrets, SLESS_ENTRYPOINT, registry secret propagation
- function_controller: добавить RegistrySecret + OperatorNamespace, копировать
  sless-registry-auth в sless-fn-<ns>, выставлять imagePullSecrets в Deployment,
  также обновлять imagePullSecrets при reconcile
- functionjob_controller: fnEnvVars включает SLESS_ENTRYPOINT, runner читает его
- server.js + server.py: читать SLESS_ENTRYPOINT вместо hardcoded handler.js/py
- rbac.yaml: добавить права на secrets
- operator.yaml: v0.1.8
- main.go: передать RegistrySecret + OperatorNamespace в FunctionReconciler
2026-03-08 11:15:48 +04:00
“Naeel” 56f05b9c7a feat: trigger.enabled + job.run_id lifecycle control (operator v0.1.6, provider v0.1.4)
- TriggerSpec.Enabled bool (default=true): enabled=false масштабирует Deployment до 0
- FunctionJobSpec.RunID int64 (default=0): run_id=0 = skip, >0 = run
- API: PATCH /v1/namespaces/{ns}/triggers/{name} (UpdateTrigger)
- Provider: enabled attribute (Optional, Computed, in-place update)
- Provider: run_id attribute (Optional, Computed, default=0, RequiresReplace)
- operator image: naeel/sless-operator:v0.1.6
- provider: terra.k8c.ru/naeel/sless v0.1.4
2026-03-08 10:10:32 +04:00
“Naeel” 6f290b3256 ops: switch registry from DockerHub to Harbor (pearlharbor.registryk8s.services.ngcloud.ru/sless) 2026-03-08 09:28:18 +04:00
“Naeel” 085db75b8a examples/hello-node: two different functions (http greeting, job sum) 2026-03-08 09:22:38 +04:00
“Naeel” ceaf2d6544 examples/hello-node: move handler to code/, split http.tf and job.tf 2026-03-08 09:14:34 +04:00
“Naeel” 1ca498bf41 examples: switch hello-node to sless_job (one-shot run) 2026-03-07 23:05:09 +04:00
“Naeel” a685d34975 examples: auto-zip via hashicorp/archive, remove manual zip step 2026-03-07 18:46:23 +04:00
“Naeel” 592d12d5e7 doc: обновлена документация — FunctionJob, proxy /fn/, operator v0.1.5, provider v0.1.3 2026-03-07 18:40:36 +04:00
“Naeel” 176b47ff1c feat: proxy /fn/{namespace}/{name} — обход wildcard DNS
Проблема: wildcard DNS *.fn.kube5s.ru недоступен.
Решение: прокси через sless-api.kube5s.ru/fn/{ns}/{name}.

- handler/invoke.go: прокси к Service функции внутри кластера
- router.go: /fn/ без auth токена, /v1/ с auth (gorilla Use())
- config.go: поле ExternalURL (EXTERNAL_URL env)
- trigger_controller.go: если ExternalURL задан — URL = ExternalURL/fn/{ns}/{fn}
  иначе fallback: Ingress + поддомен (прежнее поведение)
- operator.yaml: EXTERNAL_URL=https://sless-api.kube5s.ru, image v0.1.5

Оператор v0.1.5 задеплоен.
E2E: curl https://sless-api.kube5s.ru/fn/default/hello-node → {"message":"Hello, Naeel! (nodejs20)"}
2026-03-07 18:36:03 +04:00
“Naeel” 3d2c1ab961 feat: configurable timeouts for sless_function and sless_job
- sless_function: build_timeout_sec (optional, default 300s = 5 min)
- sless_job: wait_timeout_sec (optional, default 600s = 10 min)
- provider v0.1.3 published to terra.k8c.ru
2026-03-07 17:49:07 +04:00
“Naeel” d92845caea feat: sless_job terraform resource + jobs REST API
Operator (v0.1.4):
- internal/api/handler/jobs.go: CreateJob/GetJob/DeleteJob handlers
- internal/api/router.go: POST/GET/DELETE /v1/namespaces/{ns}/jobs/{name}

Terraform provider (v0.1.2):
- client.go: JobRequest/JobResponse + CreateJob/GetJob/DeleteJob/WaitJobDone
- resources/job_resource.go: sless_job resource (Create blocks until Succeeded/Failed)
- provider.go: register NewJobResource

Deploy:
- operator:v0.1.4 deployed and running in cluster
- provider v0.1.2 published to terra.k8c.ru
2026-03-07 17:43:26 +04:00
“Naeel” c850933c13 feat: add FunctionJob CRD for one-shot function runs
- api/v1alpha1/job_types.go: new CRD FunctionJob (Pending/Running/Succeeded/Failed)
- controllers/functionjob_controller.go: reconciler creates k8s Job from FunctionRef + EventJSON
- zz_generated.deepcopy.go: DeepCopy methods for FunctionJob types
- config/crd/bases: generated CRD YAML, applied to cluster
- main.go: register FunctionJobReconciler
- rbac.yaml: add functionjobs permissions
- operator.yaml: v0.1.2 -> v0.1.3
- operator:v0.1.3 deployed and running in cluster
2026-03-07 17:20:21 +04:00
“Naeel” c8c7dc6439 doc: document operator deploy, nodejs20 runtime, Dockerfile fixes, go.mod fix 2026-03-07 17:02:28 +04:00
“Naeel” ed8b604d76 feat: add nodejs20 runtime
- runtimes/nodejs20/server.js: HTTP wrapper, exports.handle(event)
- runtimes/nodejs20/Dockerfile: node:20-alpine base image
- naeel/sless-runtime-nodejs20:v0.1.0 pushed to DockerHub
- upload.go: nodejs20 in runtimeBaseImage(), package.json → npm install
- upload.go: python3.11 now uses v0.1.0 tag (no more latest)
- operator v0.1.2 deployed in cluster
- E2E: hello-node-default.fn.kube5s.ru → {"message":"Hello, Naeel! (nodejs20)"}
2026-03-07 17:00:29 +04:00
“Naeel” b5924f2824 chore: switch pg-query example to remote endpoint sless-api.kube5s.ru 2026-03-07 16:50:05 +04:00
“Naeel” a6c09aa601 chore: go mod tidy — fix direct/indirect annotations 2026-03-07 16:31:54 +04:00
“Naeel” c67dbcba7e feat: deploy operator in-cluster v0.1.1
- Dockerfile: fix golang 1.23→1.25, add internal/ + migrations/ to image
- deployments/k8s/operator.yaml: ConfigMap + Secret + Deployment + Service + Ingress
- Ingress: sless-api.kube5s.ru, TLS via cert-manager letsencrypt-prod
- naeel/sless-operator:v0.1.1 — running in namespace sless
- E2E: https://sless-api.kube5s.ru → HTTP/2 200
2026-03-07 16:30:14 +04:00
“Naeel” 1a5fd1825e fix: multiple fixes + pg-query e2e passed
upload.go:
- Update → Patch (MergeFrom) to avoid resourceVersion conflict when operator
  modifies Function between Get() and Update()

terraform provider v0.1.1:
- trigger_resource.go: trToModel returns StringNull() for empty schedule
  (fixes 'provider produced inconsistent result' for http triggers)
- main.go: bump version to 0.1.1

examples/pg-query:
- handler.py: fix column name started_at → created_at (matches migrations/001)
- main.tf: pin provider to ~> 0.1.1

.gitignore: add terraform state, lock, .terraform/, handler.zip

doc/errors/log.md: documented all 5 errors from this session:
- resourceVersion conflict → use Patch
- terraform inconsistent result for schedule → StringNull
- terraform import not implemented → delete+recreate workaround
- wrong column name → check migrations before writing handlers
- Deployment not restarting after image rebuild → rollout restart / TODO: restartedAt annotation

doc/progress.md: terraform apply e2e ✅
2026-03-07 10:52:29 +04:00
“Naeel” 160641737c feat: add pg-query example + upload.go supports requirements.txt
- examples/pg-query/handler.py: Python function querying PostgreSQL invocations table
- examples/pg-query/requirements.txt: psycopg2-binary==2.9.9
- examples/pg-query/main.tf: terraform config for sless_function + sless_trigger
- internal/api/handler/upload.go: generateDockerfile() now accepts hasRequirements bool
  - scans zip for requirements.txt at upload time
  - adds RUN pip install --no-cache-dir to Dockerfile when requirements.txt present
- doc/progress.md: updated status for pg-query e2e task
2026-03-07 10:30:50 +04:00
“Naeel” 18fd1d336b docs: обновить всю документацию по результатам сессии 2026-03-07
- infrastructure/overview.md: исправить S3 URL, Container Registry (DockerHub),
  namespace sless, Terraform Registry terra.k8c.ru
- errors/log.md: добавить 3 ошибки — kaniko S3 scheme, бесконечный цикл сборки,
  docker credStore desktop.exe
- decisions/log.md: добавить решения — DockerHub, terraform провайдер в той же
  репе, WaitReady паттерн, code_hash для версионирования кода
- api/design.md: обновить базовый URL, добавить upload endpoint с описанием
  процесса, исправить runtime список
2026-03-07 10:26:13 +04:00
“Naeel” d8698c7e7a feat: Terraform провайдер sless v0.1.0 + обновление doc/progress.md
Независимый Go-модуль terraform/provider/ для управления функциями через REST API.

Ресурсы:
- sless_function: CRUD + upload zip + WaitReady (kaniko build 5 мин)
- sless_trigger: CRUD (http/cron)

Компоненты:
- internal/client/client.go   — HTTP-клиент, готов к переносу в nubes
- internal/provider/provider.go — паттерн идентичен nubes NubesProvider
- internal/resources/           — function_resource.go, trigger_resource.go
- hack/build-and-publish.sh    — GPG-подпись + mc upload в S3

Опубликовано: terra.k8c.ru/naeel/sless v0.1.0
Проверено: terraform init успешен
2026-03-07 10:23:52 +04:00
“Naeel” cc72454668 fix: бесконечный цикл сборки — idempotency guard через last-built-s3key аннотацию
Проблема: upload handler сбрасывал phase в Pending ПОСЛЕ того как
контроллер уже выставил Building → бесконечный цикл (94 job'а).

Решение:
- controllers/function_controller.go: startBuild сначала ставит аннотацию
  last-built-s3key = spec.S3Key (idempotency guard), потом status.
  Reconcile стартует сборку только если spec.S3Key != last-built-s3key.
- handler/upload.go: убран Status().Update() — контроллер сам управляет фазой.
- builder.go: IsAlreadyExists при создании job не ошибка (parallel reconcile).
2026-03-07 09:50:24 +04:00
“Naeel” 9336deb49b fix: kaniko S3 endpoint — добавить https:// схему и S3_FORCE_PATH_STYLE=true
Kaniko AWS SDK требует полный URL с протоколом.
Ceph не поддерживает virtual-hosted style — нужен path style.
2026-03-07 09:39:53 +04:00
“Naeel” 8dffdc8e0f feat: upload endpoint POST /v1/namespaces/{ns}/functions/{name}/upload
- s3/client.go: Bucket() accessor + UploadContext() для tar.gz build context
- handler/upload.go: принимает zip, генерирует Dockerfile (FROM naeel/sless-runtime-{runtime}),
  перепаковывает в tar.gz, загружает в S3, обновляет Function CRD → kaniko запускается
- router.go: маршрут POST .../upload зарегистрирован
2026-03-07 09:36:32 +04:00
“Naeel” 6baea6d218 feat: python3.11 base runtime image + DockerHub registry support
- runtimes/python3.11/: Dockerfile + server.py (HTTP wrapper на :8080)
- builder.go: RegistrySecret field, volume mount docker-creds для kaniko, убран --skip-tls-verify
- config.go: добавлен RegistrySecret (env REGISTRY_SECRET, default sless-registry-auth)
- main.go: передаём RegistrySecret в builder.Config
- hack/create-registry-secret.sh: создаёт k8s Secret из DockerHub токена

Образ наружу: naeel/sless-runtime-python3.11:latest (DockerHub)
K8s secret: sless-registry-auth в namespace sless
2026-03-07 09:32:59 +04:00
“Naeel” 7243fece9f docs: обновить progress.md по фактическому состоянию
- отражён реальный статус всех компонентов кода
- отражено состояние кластера (CRD, RBAC, postgres)
- добавлен список что ещё не сделано
- добавлены deployments/k8s/rbac.yaml, postgres.yaml
2026-03-07 08:57:54 +04:00
“Naeel” 028d58cc88 feat: REST API (gorilla/mux + slog) + trigger controller + main.go wiring
- controllers/trigger_controller.go: полная реализация, HTTP->Ingress+Service, Cron->CronJob
- internal/config: добавлены IngressHost, APIToken
- internal/api/router.go: gorilla/mux роутер /v1/namespaces/{ns}/...
- internal/api/handler/: functions, triggers, invocations, base handler с slog
- internal/api/middleware/: auth (Bearer token) + logging (slog)
- main.go: запуск operator + HTTP сервера параллельно
- go.sum: добавлен gorilla/mux v1.8.1
2026-03-07 08:46:10 +04:00
“Naeel” 3d6c80f4a6 chore: ignore .s3cfg* files 2026-03-07 08:28:33 +04:00
“Naeel” 21c42d29dd feat: define Function and Trigger CRD types
Function:
  - runtime (go1.21/python3.11/nodejs20)
  - entrypoint, s3Bucket, s3Key
  - memoryMB (default 128), timeoutSec (default 30)
  - env map
  - status: phase (Pending/Building/Ready/Failed), imageRef, conditions

Trigger:
  - type (http/cron), functionRef
  - schedule (cron only)
  - preWarmSeconds (default 300) - pre-warm before cron run
  - status: active, url, lastScheduleTime

printcolumns for kubectl get output
2026-03-07 08:24:33 +04:00
“Naeel” e3cf383daa feat: operator-sdk scaffold with Function and Trigger CRDs
- operator-sdk init --domain kube5s.ru
- CRD: Function, Trigger (sless.kube5s.ru)
- controllers: FunctionReconciler, TriggerReconciler (stubs)
- internal/ structure: api, storage/postgres, storage/s3, builder, config
- fix: controller-tools bumped v0.11.1→v0.14.0 (Go 1.23 compat)
2026-03-07 08:16:39 +04:00
132 changed files with 11908 additions and 23 deletions
+4
View File
@@ -0,0 +1,4 @@
# More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file
# Ignore build and test binaries.
bin/
testbin/
+49
View File
@@ -0,0 +1,49 @@
# S3 конфиги с кредами — не коммитим
.s3cfg*
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
bin
testbin/*
hack/local.env
Dockerfile.cross
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Kubernetes Generated files - skip generated files, except for vendored files
!vendor/**/zz_generated.*
# editor and IDE paraphernalia
.idea
*.swp
*.swo
*~
terraform/provider/build/
# Terraform state и кэш провайдеров (не коммитим)
**/.terraform/
**/.terraform.lock.hcl
**/terraform.tfstate
**/terraform.tfstate.backup
**/terraform.tfstate.*.backup
# Собранные zip-архивы функций (генерируются при terraform apply)
examples/*/dist/
**/handler.zip
test.token
/deployments/k8s/operator-demo.yaml
/doc/decisions/log.md
/doc/log.md
/s3cfg_registry
/sqs-operator/quick_test.sh
/sqs-operator/sqs_load_test.sh
/.s3cfg_registry
+33
View File
@@ -0,0 +1,33 @@
# Изменено: 2026-03-07
# Multi-stage build для sless оператора.
# Stage 1: сборка бинаря (golang:1.23-alpine)
# Stage 2: минимальный образ (alpine:3.19, не distroless — нужен ca-certificates для S3/HTTPS)
FROM golang:1.25-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
WORKDIR /workspace
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download
# Копируем весь исходный код (включая internal/ — там основная логика)
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/
COPY internal/ internal/
COPY migrations/ migrations/
RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager main.go
FROM alpine:3.19
# ca-certificates нужны для TLS (S3 HTTPS, DockerHub)
RUN apk add --no-cache ca-certificates
WORKDIR /
COPY --from=builder /workspace/manager .
# migrations нужны при старте — оператор читает SQL файлы для инициализации БД
COPY migrations/ migrations/
# Запускаем от непривилегированного пользователя
USER 65532:65532
ENTRYPOINT ["/manager"]
+260
View File
@@ -0,0 +1,260 @@
# VERSION defines the project version for the bundle.
# Update this value when you upgrade the version of your project.
# To re-generate a bundle for another specific version without changing the standard setup, you can:
# - use the VERSION as arg of the bundle target (e.g make bundle VERSION=0.0.2)
# - use environment variables to overwrite this value (e.g export VERSION=0.0.2)
VERSION ?= 0.0.1
# CHANNELS define the bundle channels used in the bundle.
# Add a new line here if you would like to change its default config. (E.g CHANNELS = "candidate,fast,stable")
# To re-generate a bundle for other specific channels without changing the standard setup, you can:
# - use the CHANNELS as arg of the bundle target (e.g make bundle CHANNELS=candidate,fast,stable)
# - use environment variables to overwrite this value (e.g export CHANNELS="candidate,fast,stable")
ifneq ($(origin CHANNELS), undefined)
BUNDLE_CHANNELS := --channels=$(CHANNELS)
endif
# DEFAULT_CHANNEL defines the default channel used in the bundle.
# Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable")
# To re-generate a bundle for any other default channel without changing the default setup, you can:
# - use the DEFAULT_CHANNEL as arg of the bundle target (e.g make bundle DEFAULT_CHANNEL=stable)
# - use environment variables to overwrite this value (e.g export DEFAULT_CHANNEL="stable")
ifneq ($(origin DEFAULT_CHANNEL), undefined)
BUNDLE_DEFAULT_CHANNEL := --default-channel=$(DEFAULT_CHANNEL)
endif
BUNDLE_METADATA_OPTS ?= $(BUNDLE_CHANNELS) $(BUNDLE_DEFAULT_CHANNEL)
# IMAGE_TAG_BASE defines the docker.io namespace and part of the image name for remote images.
# This variable is used to construct full image tags for bundle and catalog images.
#
# For example, running 'make bundle-build bundle-push catalog-build catalog-push' will build and push both
# kube5s.ru/sless-bundle:$VERSION and kube5s.ru/sless-catalog:$VERSION.
IMAGE_TAG_BASE ?= kube5s.ru/sless
# BUNDLE_IMG defines the image:tag used for the bundle.
# You can use it as an arg. (E.g make bundle-build BUNDLE_IMG=<some-registry>/<project-name-bundle>:<tag>)
BUNDLE_IMG ?= $(IMAGE_TAG_BASE)-bundle:v$(VERSION)
# BUNDLE_GEN_FLAGS are the flags passed to the operator-sdk generate bundle command
BUNDLE_GEN_FLAGS ?= -q --overwrite --version $(VERSION) $(BUNDLE_METADATA_OPTS)
# USE_IMAGE_DIGESTS defines if images are resolved via tags or digests
# You can enable this value if you would like to use SHA Based Digests
# To enable set flag to true
USE_IMAGE_DIGESTS ?= false
ifeq ($(USE_IMAGE_DIGESTS), true)
BUNDLE_GEN_FLAGS += --use-image-digests
endif
# Image URL to use all building/pushing image targets
IMG ?= controller:latest
# ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary.
ENVTEST_K8S_VERSION = 1.26.0
# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set)
ifeq (,$(shell go env GOBIN))
GOBIN=$(shell go env GOPATH)/bin
else
GOBIN=$(shell go env GOBIN)
endif
# Setting SHELL to bash allows bash commands to be executed by recipes.
# Options are set to exit when a recipe line exits non-zero or a piped command fails.
SHELL = /usr/bin/env bash -o pipefail
.SHELLFLAGS = -ec
.PHONY: all
all: build
##@ General
# The help target prints out all targets with their descriptions organized
# beneath their categories. The categories are represented by '##@' and the
# target descriptions by '##'. The awk commands is responsible for reading the
# entire set of makefiles included in this invocation, looking for lines of the
# file as xyz: ## something, and then pretty-format the target and help. Then,
# if there's a line with ##@ something, that gets pretty-printed as a category.
# More info on the usage of ANSI control characters for terminal formatting:
# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters
# More info on the awk command:
# http://linuxcommand.org/lc3_adv_awk.php
.PHONY: help
help: ## Display this help.
@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m<target>\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
##@ Development
.PHONY: manifests
manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
.PHONY: generate
generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
.PHONY: fmt
fmt: ## Run go fmt against code.
go fmt ./...
.PHONY: vet
vet: ## Run go vet against code.
go vet ./...
.PHONY: test
test: manifests generate fmt vet envtest ## Run tests.
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test ./... -coverprofile cover.out
##@ Build
.PHONY: build
build: manifests generate fmt vet ## Build manager binary.
go build -o bin/manager main.go
.PHONY: run
run: manifests generate fmt vet ## Run a controller from your host.
go run ./main.go
# If you wish built the manager image targeting other platforms you can use the --platform flag.
# (i.e. docker build --platform linux/arm64 ). However, you must enable docker buildKit for it.
# More info: https://docs.docker.com/develop/develop-images/build_enhancements/
.PHONY: docker-build
docker-build: test ## Build docker image with the manager.
docker build -t ${IMG} .
.PHONY: docker-push
docker-push: ## Push docker image with the manager.
docker push ${IMG}
# PLATFORMS defines the target platforms for the manager image be build to provide support to multiple
# architectures. (i.e. make docker-buildx IMG=myregistry/mypoperator:0.0.1). To use this option you need to:
# - able to use docker buildx . More info: https://docs.docker.com/build/buildx/
# - have enable BuildKit, More info: https://docs.docker.com/develop/develop-images/build_enhancements/
# - be able to push the image for your registry (i.e. if you do not inform a valid value via IMG=<myregistry/image:<tag>> then the export will fail)
# To properly provided solutions that supports more than one platform you should use this option.
PLATFORMS ?= linux/arm64,linux/amd64,linux/s390x,linux/ppc64le
.PHONY: docker-buildx
docker-buildx: test ## Build and push docker image for the manager for cross-platform support
# copy existing Dockerfile and insert --platform=${BUILDPLATFORM} into Dockerfile.cross, and preserve the original Dockerfile
sed -e '1 s/\(^FROM\)/FROM --platform=\$$\{BUILDPLATFORM\}/; t' -e ' 1,// s//FROM --platform=\$$\{BUILDPLATFORM\}/' Dockerfile > Dockerfile.cross
- docker buildx create --name project-v3-builder
docker buildx use project-v3-builder
- docker buildx build --push --platform=$(PLATFORMS) --tag ${IMG} -f Dockerfile.cross .
- docker buildx rm project-v3-builder
rm Dockerfile.cross
##@ Deployment
ifndef ignore-not-found
ignore-not-found = false
endif
.PHONY: install
install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config.
$(KUSTOMIZE) build config/crd | kubectl apply -f -
.PHONY: uninstall
uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
$(KUSTOMIZE) build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f -
.PHONY: deploy
deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config.
cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG}
$(KUSTOMIZE) build config/default | kubectl apply -f -
.PHONY: undeploy
undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
$(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f -
##@ Build Dependencies
## Location to install dependencies to
LOCALBIN ?= $(shell pwd)/bin
$(LOCALBIN):
mkdir -p $(LOCALBIN)
## Tool Binaries
KUSTOMIZE ?= $(LOCALBIN)/kustomize
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
ENVTEST ?= $(LOCALBIN)/setup-envtest
## Tool Versions
KUSTOMIZE_VERSION ?= v3.8.7
CONTROLLER_TOOLS_VERSION ?= v0.14.0
KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh"
.PHONY: kustomize
kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. If wrong version is installed, it will be removed before downloading.
$(KUSTOMIZE): $(LOCALBIN)
@if test -x $(LOCALBIN)/kustomize && ! $(LOCALBIN)/kustomize version | grep -q $(KUSTOMIZE_VERSION); then \
echo "$(LOCALBIN)/kustomize version is not expected $(KUSTOMIZE_VERSION). Removing it before installing."; \
rm -rf $(LOCALBIN)/kustomize; \
fi
test -s $(LOCALBIN)/kustomize || { curl -Ss $(KUSTOMIZE_INSTALL_SCRIPT) | bash -s -- $(subst v,,$(KUSTOMIZE_VERSION)) $(LOCALBIN); }
.PHONY: controller-gen
controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. If wrong version is installed, it will be overwritten.
$(CONTROLLER_GEN): $(LOCALBIN)
test -s $(LOCALBIN)/controller-gen && $(LOCALBIN)/controller-gen --version | grep -q $(CONTROLLER_TOOLS_VERSION) || \
GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION)
.PHONY: envtest
envtest: $(ENVTEST) ## Download envtest-setup locally if necessary.
$(ENVTEST): $(LOCALBIN)
test -s $(LOCALBIN)/setup-envtest || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest
.PHONY: bundle
bundle: manifests kustomize ## Generate bundle manifests and metadata, then validate generated files.
operator-sdk generate kustomize manifests -q
cd config/manager && $(KUSTOMIZE) edit set image controller=$(IMG)
$(KUSTOMIZE) build config/manifests | operator-sdk generate bundle $(BUNDLE_GEN_FLAGS)
operator-sdk bundle validate ./bundle
.PHONY: bundle-build
bundle-build: ## Build the bundle image.
docker build -f bundle.Dockerfile -t $(BUNDLE_IMG) .
.PHONY: bundle-push
bundle-push: ## Push the bundle image.
$(MAKE) docker-push IMG=$(BUNDLE_IMG)
.PHONY: opm
OPM = ./bin/opm
opm: ## Download opm locally if necessary.
ifeq (,$(wildcard $(OPM)))
ifeq (,$(shell which opm 2>/dev/null))
@{ \
set -e ;\
mkdir -p $(dir $(OPM)) ;\
OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \
curl -sSLo $(OPM) https://github.com/operator-framework/operator-registry/releases/download/v1.23.0/$${OS}-$${ARCH}-opm ;\
chmod +x $(OPM) ;\
}
else
OPM = $(shell which opm)
endif
endif
# A comma-separated list of bundle images (e.g. make catalog-build BUNDLE_IMGS=example.com/operator-bundle:v0.1.0,example.com/operator-bundle:v0.2.0).
# These images MUST exist in a registry and be pull-able.
BUNDLE_IMGS ?= $(BUNDLE_IMG)
# The image tag given to the resulting catalog image (e.g. make catalog-build CATALOG_IMG=example.com/operator-catalog:v0.2.0).
CATALOG_IMG ?= $(IMAGE_TAG_BASE)-catalog:v$(VERSION)
# Set CATALOG_BASE_IMG to an existing catalog image tag to add $BUNDLE_IMGS to that image.
ifneq ($(origin CATALOG_BASE_IMG), undefined)
FROM_INDEX_OPT := --from-index $(CATALOG_BASE_IMG)
endif
# Build a catalog image by adding bundle images to an empty catalog using the operator package manager tool, 'opm'.
# This recipe invokes 'opm' in 'semver' bundle add mode. For more information on add modes, see:
# https://github.com/operator-framework/community-operators/blob/7f1438c/docs/packaging-operator.md#updating-your-existing-operator
.PHONY: catalog-build
catalog-build: opm ## Build a catalog image.
$(OPM) index add --container-tool docker --mode semver --tag $(CATALOG_IMG) --bundles $(BUNDLE_IMGS) $(FROM_INDEX_OPT)
# Push the catalog image.
.PHONY: catalog-push
catalog-push: ## Push a catalog image.
$(MAKE) docker-push IMG=$(CATALOG_IMG)
+32
View File
@@ -0,0 +1,32 @@
# Code generated by tool. DO NOT EDIT.
# This file is used to track the info used to scaffold your project
# and allow the plugins properly work.
# More info: https://book.kubebuilder.io/reference/project-config.html
domain: kube5s.ru
layout:
- go.kubebuilder.io/v3
plugins:
manifests.sdk.operatorframework.io/v2: {}
scorecard.sdk.operatorframework.io/v2: {}
projectName: sless
repo: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
resources:
- api:
crdVersion: v1
namespaced: true
controller: true
domain: kube5s.ru
group: sless
kind: Function
path: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1
version: v1alpha1
- api:
crdVersion: v1
namespaced: true
controller: true
domain: kube5s.ru
group: sless
kind: Trigger
path: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1
version: v1alpha1
version: "3"
+94
View File
@@ -0,0 +1,94 @@
# sless
// TODO(user): Add simple overview of use/purpose
## Description
// TODO(user): An in-depth paragraph about your project and overview of use
## Getting Started
You’ll need a Kubernetes cluster to run against. You can use [KIND](https://sigs.k8s.io/kind) to get a local cluster for testing, or run against a remote cluster.
**Note:** Your controller will automatically use the current context in your kubeconfig file (i.e. whatever cluster `kubectl cluster-info` shows).
### Running on the cluster
1. Install Instances of Custom Resources:
```sh
kubectl apply -f config/samples/
```
2. Build and push your image to the location specified by `IMG`:
```sh
make docker-build docker-push IMG=<some-registry>/sless:tag
```
3. Deploy the controller to the cluster with the image specified by `IMG`:
```sh
make deploy IMG=<some-registry>/sless:tag
```
### Uninstall CRDs
To delete the CRDs from the cluster:
```sh
make uninstall
```
### Undeploy controller
UnDeploy the controller from the cluster:
```sh
make undeploy
```
## Contributing
// TODO(user): Add detailed information on how you would like others to contribute to this project
### How it works
This project aims to follow the Kubernetes [Operator pattern](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/).
It uses [Controllers](https://kubernetes.io/docs/concepts/architecture/controller/),
which provide a reconcile function responsible for synchronizing resources until the desired state is reached on the cluster.
### Test It Out
1. Install the CRDs into the cluster:
```sh
make install
```
2. Run your controller (this will run in the foreground, so switch to a new terminal if you want to leave it running):
```sh
make run
```
**NOTE:** You can also run this in one step by running: `make install run`
### Modifying the API definitions
If you are editing the API definitions, generate the manifests such as CRs or CRDs using:
```sh
make manifests
```
**NOTE:** Run `make --help` for more information on all potential `make` targets
More information can be found via the [Kubebuilder Documentation](https://book.kubebuilder.io/introduction.html)
## License
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+100
View File
@@ -0,0 +1,100 @@
// Изменено: 2026-03-07
// Описание CRD Function — основной ресурс serverless функции.
// Пользователь создаёт Function, контроллер строит образ и деплоит в k8s.
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// FunctionSpec — желаемое состояние функции.
// Описывает всё необходимое для сборки и запуска пользовательского кода.
type FunctionSpec struct {
// Runtime — язык и версия выполнения (go1.21, python3.11, nodejs20)
// +kubebuilder:validation:Enum=go1.21;python3.11;nodejs20
// +kubebuilder:validation:Required
Runtime string `json:"runtime"`
// Entrypoint — точка входа в код функции (например: handler.handle)
// +kubebuilder:validation:Required
Entrypoint string `json:"entrypoint"`
// S3Bucket — бакет S3 где хранится zip архив с кодом
S3Bucket string `json:"s3Bucket"`
// S3Key — ключ объекта в S3 (путь до zip архива)
S3Key string `json:"s3Key"`
// MemoryMB — лимит памяти в мегабайтах (default: 128)
// +kubebuilder:default=128
MemoryMB int32 `json:"memoryMB,omitempty"`
// TimeoutSec — максимальное время выполнения в секундах (default: 30)
// +kubebuilder:default=30
TimeoutSec int32 `json:"timeoutSec,omitempty"`
// Env — переменные окружения, передаются в контейнер функции
Env map[string]string `json:"env,omitempty"`
}
// FunctionPhase — текущая фаза жизненного цикла функции.
// Используем строковый тип чтобы было читаемо в kubectl get functions.
type FunctionPhase string
const (
// FunctionPhasePending — функция создана, ожидает сборки образа
FunctionPhasePending FunctionPhase = "Pending"
// FunctionPhaseBuilding — идёт сборка Docker образа
FunctionPhaseBuilding FunctionPhase = "Building"
// FunctionPhaseReady — образ собран, функция готова к вызову
FunctionPhaseReady FunctionPhase = "Ready"
// FunctionPhaseFailed — ошибка при сборке или деплое
FunctionPhaseFailed FunctionPhase = "Failed"
)
// FunctionStatus — наблюдаемое состояние функции (заполняет контроллер).
type FunctionStatus struct {
// Phase — текущая фаза: Pending, Building, Ready, Failed
Phase FunctionPhase `json:"phase,omitempty"`
// ImageRef — полный путь к собранному Docker образу в registry
ImageRef string `json:"imageRef,omitempty"`
// Message — человекочитаемое сообщение об ошибке или статусе
Message string `json:"message,omitempty"`
// Conditions — стандартные k8s conditions для интеграции с инструментами
Conditions []metav1.Condition `json:"conditions,omitempty"`
// LastBuiltAt — время последней успешной сборки образа
LastBuiltAt *metav1.Time `json:"lastBuiltAt,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Runtime",type=string,JSONPath=`.spec.runtime`
//+kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Function — основной ресурс для управления serverless функцией.
type Function struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FunctionSpec `json:"spec,omitempty"`
Status FunctionStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// FunctionList contains a list of Function
type FunctionList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Function `json:"items"`
}
func init() {
SchemeBuilder.Register(&Function{}, &FunctionList{})
}
+36
View File
@@ -0,0 +1,36 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package v1alpha1 contains API Schema definitions for the sless v1alpha1 API group
// +kubebuilder:object:generate=true
// +groupName=sless.kube5s.ru
package v1alpha1
import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
var (
// GroupVersion is group version used to register these objects
GroupVersion = schema.GroupVersion{Group: "sless.kube5s.ru", Version: "v1alpha1"}
// SchemeBuilder is used to add go types to the GroupVersionKind scheme
SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion}
// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
+96
View File
@@ -0,0 +1,96 @@
// Изменено: 2026-03-08
// Описание CRD FunctionJob — одноразовый запуск функции.
// Отдельный ресурс (не Trigger) потому что семантика принципиально другая:
// - Trigger: постоянно живёт, описывает КАК функцию вызывают (http/cron)
// - FunctionJob: запускается один раз, завершается, хранит результат
//
// Terraform ресурс: sless_job
// k8s ресурс: k8s Job в namespace sless-fn-{namespace}
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// FunctionJobSpec — параметры одноразового запуска функции.
type FunctionJobSpec struct {
// RunID — идентификатор запуска. 0 = не запускать.
// Каждое ненулевое значение уникально идентифицирует запуск.
// Увеличь RunID (1→2→3) для повторного запуска джоба.
// При RunID=0 FunctionJob создаётся в кластере, но k8s Job не запускается.
// +kubebuilder:default=0
RunID int64 `json:"runId"`
// FunctionRef — имя Function ресурса в том же namespace
// +kubebuilder:validation:Required
FunctionRef string `json:"functionRef"`
// EventJSON — данные передаваемые в handle(event) в JSON формате.
// Если не задан — передаётся пустой объект {}.
// Пример: {"action": "migrate", "version": "002"}
// +kubebuilder:default="{}"
EventJSON string `json:"eventJson,omitempty"`
}
// FunctionJobPhase — фаза жизненного цикла одноразового запуска.
type FunctionJobPhase string
const (
// FunctionJobPhasePending — ожидает пока Function станет Ready
FunctionJobPhasePending FunctionJobPhase = "Pending"
// FunctionJobPhaseRunning — k8s Job запущен, функция выполняется
FunctionJobPhaseRunning FunctionJobPhase = "Running"
// FunctionJobPhaseSucceeded — функция успешно завершилась
FunctionJobPhaseSucceeded FunctionJobPhase = "Succeeded"
// FunctionJobPhaseFailed — функция завершилась с ошибкой
FunctionJobPhaseFailed FunctionJobPhase = "Failed"
)
// FunctionJobStatus — наблюдаемое состояние (заполняет контроллер).
type FunctionJobStatus struct {
// Phase — текущая фаза: Pending, Running, Succeeded, Failed
Phase FunctionJobPhase `json:"phase,omitempty"`
// JobName — имя созданного k8s Job
JobName string `json:"jobName,omitempty"`
// StartTime — время запуска k8s Job
StartTime *metav1.Time `json:"startTime,omitempty"`
// CompletionTime — время завершения
CompletionTime *metav1.Time `json:"completionTime,omitempty"`
// Message — результат выполнения или сообщение об ошибке
Message string `json:"message,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
//+kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// FunctionJob — ресурс для одноразового запуска serverless функции.
// Создаётся пользователем через terraform ресурс sless_job.
// После завершения (Succeeded/Failed) ресурс остаётся в кластере для аудита.
type FunctionJob struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec FunctionJobSpec `json:"spec,omitempty"`
Status FunctionJobStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// FunctionJobList contains a list of FunctionJob
type FunctionJobList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []FunctionJob `json:"items"`
}
func init() {
SchemeBuilder.Register(&FunctionJob{}, &FunctionJobList{})
}
+90
View File
@@ -0,0 +1,90 @@
// Изменено: 2026-03-08
// Описание CRD Trigger — триггер для функции (HTTP или Cron).
// Один Trigger ссылается на одну Function и определяет способ вызова.
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// TriggerType — тип триггера.
type TriggerType string
const (
// TriggerTypeHTTP — функция вызывается по HTTP запросу через Ingress
TriggerTypeHTTP TriggerType = "http"
// TriggerTypeCron — функция вызывается по расписанию (k8s CronJob)
TriggerTypeCron TriggerType = "cron"
)
// TriggerSpec — желаемое состояние триггера.
type TriggerSpec struct {
// Enabled — если false, Deployment функции масштабируется до 0 (функция не принимает запросы).
// При true — функция запускается. По умолчанию true.
// Позволяет "заморозить" функцию без удаления ресурса.
// +kubebuilder:default=true
Enabled bool `json:"enabled"`
// FunctionRef — имя Function ресурса в том же namespace
// +kubebuilder:validation:Required
FunctionRef string `json:"functionRef"`
// Type — тип триггера: http или cron
// +kubebuilder:validation:Enum=http;cron
// +kubebuilder:validation:Required
Type TriggerType `json:"type"`
// Schedule — расписание в формате cron (только для type=cron, например: "0 2 * * *")
Schedule string `json:"schedule,omitempty"`
// PreWarmSeconds — за сколько секунд до запуска прогреть контейнер.
// Актуально для cron: запускаем pod заранее чтобы избежать cold start.
// +kubebuilder:default=300
PreWarmSeconds int32 `json:"preWarmSeconds,omitempty"`
}
// TriggerStatus — наблюдаемое состояние триггера (заполняет контроллер).
type TriggerStatus struct {
// Active — активен ли триггер в данный момент
Active bool `json:"active,omitempty"`
// URL — публичный URL для вызова функции (только для type=http)
URL string `json:"url,omitempty"`
// LastScheduleTime — время последнего запуска по расписанию (только для type=cron)
LastScheduleTime *metav1.Time `json:"lastScheduleTime,omitempty"`
// Message — человекочитаемое сообщение об ошибке или статусе
Message string `json:"message,omitempty"`
}
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type`
//+kubebuilder:printcolumn:name="Function",type=string,JSONPath=`.spec.functionRef`
//+kubebuilder:printcolumn:name="Active",type=boolean,JSONPath=`.status.active`
//+kubebuilder:printcolumn:name="URL",type=string,JSONPath=`.status.url`
//+kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
// Trigger — ресурс для управления способом вызова Function.
type Trigger struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec TriggerSpec `json:"spec,omitempty"`
Status TriggerStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// TriggerList contains a list of Trigger
type TriggerList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Trigger `json:"items"`
}
func init() {
SchemeBuilder.Register(&Trigger{}, &TriggerList{})
}
+323
View File
@@ -0,0 +1,323 @@
//go:build !ignore_autogenerated
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Code generated by controller-gen. DO NOT EDIT.
package v1alpha1
import (
"k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Function) DeepCopyInto(out *Function) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Function.
func (in *Function) DeepCopy() *Function {
if in == nil {
return nil
}
out := new(Function)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Function) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionJob) DeepCopyInto(out *FunctionJob) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJob.
func (in *FunctionJob) DeepCopy() *FunctionJob {
if in == nil {
return nil
}
out := new(FunctionJob)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FunctionJob) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionJobList) DeepCopyInto(out *FunctionJobList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]FunctionJob, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobList.
func (in *FunctionJobList) DeepCopy() *FunctionJobList {
if in == nil {
return nil
}
out := new(FunctionJobList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FunctionJobList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionJobSpec) DeepCopyInto(out *FunctionJobSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobSpec.
func (in *FunctionJobSpec) DeepCopy() *FunctionJobSpec {
if in == nil {
return nil
}
out := new(FunctionJobSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionJobStatus) DeepCopyInto(out *FunctionJobStatus) {
*out = *in
if in.StartTime != nil {
in, out := &in.StartTime, &out.StartTime
*out = (*in).DeepCopy()
}
if in.CompletionTime != nil {
in, out := &in.CompletionTime, &out.CompletionTime
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobStatus.
func (in *FunctionJobStatus) DeepCopy() *FunctionJobStatus {
if in == nil {
return nil
}
out := new(FunctionJobStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionList) DeepCopyInto(out *FunctionList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Function, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionList.
func (in *FunctionList) DeepCopy() *FunctionList {
if in == nil {
return nil
}
out := new(FunctionList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *FunctionList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionSpec) DeepCopyInto(out *FunctionSpec) {
*out = *in
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make(map[string]string, len(*in))
for key, val := range *in {
(*out)[key] = val
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionSpec.
func (in *FunctionSpec) DeepCopy() *FunctionSpec {
if in == nil {
return nil
}
out := new(FunctionSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *FunctionStatus) DeepCopyInto(out *FunctionStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.LastBuiltAt != nil {
in, out := &in.LastBuiltAt, &out.LastBuiltAt
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionStatus.
func (in *FunctionStatus) DeepCopy() *FunctionStatus {
if in == nil {
return nil
}
out := new(FunctionStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Trigger) DeepCopyInto(out *Trigger) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Trigger.
func (in *Trigger) DeepCopy() *Trigger {
if in == nil {
return nil
}
out := new(Trigger)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *Trigger) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TriggerList) DeepCopyInto(out *TriggerList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]Trigger, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggerList.
func (in *TriggerList) DeepCopy() *TriggerList {
if in == nil {
return nil
}
out := new(TriggerList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TriggerList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TriggerSpec) DeepCopyInto(out *TriggerSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggerSpec.
func (in *TriggerSpec) DeepCopy() *TriggerSpec {
if in == nil {
return nil
}
out := new(TriggerSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TriggerStatus) DeepCopyInto(out *TriggerStatus) {
*out = *in
if in.LastScheduleTime != nil {
in, out := &in.LastScheduleTime, &out.LastScheduleTime
*out = (*in).DeepCopy()
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TriggerStatus.
func (in *TriggerStatus) DeepCopy() *TriggerStatus {
if in == nil {
return nil
}
out := new(TriggerStatus)
in.DeepCopyInto(out)
return out
}
@@ -0,0 +1,103 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.14.0
name: functionjobs.sless.kube5s.ru
spec:
group: sless.kube5s.ru
names:
kind: FunctionJob
listKind: FunctionJobList
plural: functionjobs
singular: functionjob
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.functionRef
name: Function
type: string
- jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: |-
FunctionJob — ресурс для одноразового запуска serverless функции.
Создаётся пользователем через terraform ресурс sless_job.
После завершения (Succeeded/Failed) ресурс остаётся в кластере для аудита.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: FunctionJobSpec — параметры одноразового запуска функции.
properties:
eventJson:
default: '{}'
description: |-
EventJSON — данные передаваемые в handle(event) в JSON формате.
Если не задан — передаётся пустой объект {}.
Пример: {"action": "migrate", "version": "002"}
type: string
functionRef:
description: FunctionRef — имя Function ресурса в том же namespace
type: string
runId:
default: 0
description: |-
RunID — идентификатор запуска. 0 = не запускать.
Каждое ненулевое значение уникально идентифицирует запуск.
Увеличь RunID (1→2→3) для повторного запуска джоба.
При RunID=0 FunctionJob создаётся в кластере, но k8s Job не запускается.
format: int64
type: integer
required:
- functionRef
- runId
type: object
status:
description: FunctionJobStatus — наблюдаемое состояние (заполняет контроллер).
properties:
completionTime:
description: CompletionTime — время завершения
format: date-time
type: string
jobName:
description: JobName — имя созданного k8s Job
type: string
message:
description: Message — результат выполнения или сообщение об ошибке
type: string
phase:
description: 'Phase — текущая фаза: Pending, Running, Succeeded, Failed'
type: string
startTime:
description: StartTime — время запуска k8s Job
format: date-time
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,186 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.14.0
name: functions.sless.kube5s.ru
spec:
group: sless.kube5s.ru
names:
kind: Function
listKind: FunctionList
plural: functions
singular: function
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.runtime
name: Runtime
type: string
- jsonPath: .status.phase
name: Phase
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: Function — основной ресурс для управления serverless функцией.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: |-
FunctionSpec — желаемое состояние функции.
Описывает всё необходимое для сборки и запуска пользовательского кода.
properties:
entrypoint:
description: 'Entrypoint — точка входа в код функции (например: handler.handle)'
type: string
env:
additionalProperties:
type: string
description: Env — переменные окружения, передаются в контейнер функции
type: object
memoryMB:
default: 128
description: 'MemoryMB — лимит памяти в мегабайтах (default: 128)'
format: int32
type: integer
runtime:
description: Runtime — язык и версия выполнения (go1.21, python3.11,
nodejs20)
enum:
- go1.21
- python3.11
- nodejs20
type: string
s3Bucket:
description: S3Bucket — бакет S3 где хранится zip архив с кодом
type: string
s3Key:
description: S3Key — ключ объекта в S3 (путь до zip архива)
type: string
timeoutSec:
default: 30
description: 'TimeoutSec — максимальное время выполнения в секундах
(default: 30)'
format: int32
type: integer
required:
- entrypoint
- runtime
- s3Bucket
- s3Key
type: object
status:
description: FunctionStatus — наблюдаемое состояние функции (заполняет
контроллер).
properties:
conditions:
description: Conditions — стандартные k8s conditions для интеграции
с инструментами
items:
description: "Condition contains details for one aspect of the current
state of this API Resource.\n---\nThis struct is intended for
direct use as an array at the field path .status.conditions. For
example,\n\n\n\ttype FooStatus struct{\n\t // Represents the
observations of a foo's current state.\n\t // Known .status.conditions.type
are: \"Available\", \"Progressing\", and \"Degraded\"\n\t //
+patchMergeKey=type\n\t // +patchStrategy=merge\n\t // +listType=map\n\t
\ // +listMapKey=type\n\t Conditions []metav1.Condition `json:\"conditions,omitempty\"
patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`\n\n\n\t
\ // other fields\n\t}"
properties:
lastTransitionTime:
description: |-
lastTransitionTime is the last time the condition transitioned from one status to another.
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
format: date-time
type: string
message:
description: |-
message is a human readable message indicating details about the transition.
This may be an empty string.
maxLength: 32768
type: string
observedGeneration:
description: |-
observedGeneration represents the .metadata.generation that the condition was set based upon.
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
with respect to the current state of the instance.
format: int64
minimum: 0
type: integer
reason:
description: |-
reason contains a programmatic identifier indicating the reason for the condition's last transition.
Producers of specific condition types may define expected values and meanings for this field,
and whether the values are considered a guaranteed API.
The value should be a CamelCase string.
This field may not be empty.
maxLength: 1024
minLength: 1
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
type: string
status:
description: status of the condition, one of True, False, Unknown.
enum:
- "True"
- "False"
- Unknown
type: string
type:
description: |-
type of condition in CamelCase or in foo.example.com/CamelCase.
---
Many .condition.type values are consistent across resources like Available, but because arbitrary conditions can be
useful (see .node.status.conditions), the ability to deconflict is important.
The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
maxLength: 316
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
type: string
required:
- lastTransitionTime
- message
- reason
- status
- type
type: object
type: array
imageRef:
description: ImageRef — полный путь к собранному Docker образу в registry
type: string
lastBuiltAt:
description: LastBuiltAt — время последней успешной сборки образа
format: date-time
type: string
message:
description: Message — человекочитаемое сообщение об ошибке или статусе
type: string
phase:
description: 'Phase — текущая фаза: Pending, Building, Ready, Failed'
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
@@ -0,0 +1,113 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
controller-gen.kubebuilder.io/version: v0.14.0
name: triggers.sless.kube5s.ru
spec:
group: sless.kube5s.ru
names:
kind: Trigger
listKind: TriggerList
plural: triggers
singular: trigger
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: .spec.type
name: Type
type: string
- jsonPath: .spec.functionRef
name: Function
type: string
- jsonPath: .status.active
name: Active
type: boolean
- jsonPath: .status.url
name: URL
type: string
- jsonPath: .metadata.creationTimestamp
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: Trigger — ресурс для управления способом вызова Function.
properties:
apiVersion:
description: |-
APIVersion defines the versioned schema of this representation of an object.
Servers should convert recognized schemas to the latest internal value, and
may reject unrecognized values.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
type: string
kind:
description: |-
Kind is a string value representing the REST resource this object represents.
Servers may infer this from the endpoint the client submits requests to.
Cannot be updated.
In CamelCase.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
type: string
metadata:
type: object
spec:
description: TriggerSpec — желаемое состояние триггера.
properties:
enabled:
default: true
description: |-
Enabled — если false, Deployment функции масштабируется до 0 (функция не принимает запросы).
При true — функция запускается. По умолчанию true.
Позволяет "заморозить" функцию без удаления ресурса.
type: boolean
functionRef:
description: FunctionRef — имя Function ресурса в том же namespace
type: string
preWarmSeconds:
default: 300
description: |-
PreWarmSeconds — за сколько секунд до запуска прогреть контейнер.
Актуально для cron: запускаем pod заранее чтобы избежать cold start.
format: int32
type: integer
schedule:
description: 'Schedule — расписание в формате cron (только для type=cron,
например: "0 2 * * *")'
type: string
type:
description: 'Type — тип триггера: http или cron'
enum:
- http
- cron
type: string
required:
- enabled
- functionRef
- type
type: object
status:
description: TriggerStatus — наблюдаемое состояние триггера (заполняет
контроллер).
properties:
active:
description: Active — активен ли триггер в данный момент
type: boolean
lastScheduleTime:
description: LastScheduleTime — время последнего запуска по расписанию
(только для type=cron)
format: date-time
type: string
message:
description: Message — человекочитаемое сообщение об ошибке или статусе
type: string
url:
description: URL — публичный URL для вызова функции (только для type=http)
type: string
type: object
type: object
served: true
storage: true
subresources:
status: {}
+24
View File
@@ -0,0 +1,24 @@
# This kustomization.yaml is not intended to be run by itself,
# since it depends on service name and namespace that are out of this kustomize package.
# It should be run by config/default
resources:
- bases/sless.kube5s.ru_functions.yaml
- bases/sless.kube5s.ru_triggers.yaml
#+kubebuilder:scaffold:crdkustomizeresource
patchesStrategicMerge:
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix.
# patches here are for enabling the conversion webhook for each CRD
#- patches/webhook_in_functions.yaml
#- patches/webhook_in_triggers.yaml
#+kubebuilder:scaffold:crdkustomizewebhookpatch
# [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix.
# patches here are for enabling the CA injection for each CRD
#- patches/cainjection_in_functions.yaml
#- patches/cainjection_in_triggers.yaml
#+kubebuilder:scaffold:crdkustomizecainjectionpatch
# the following config is for teaching kustomize how to do kustomization for CRDs.
configurations:
- kustomizeconfig.yaml
+19
View File
@@ -0,0 +1,19 @@
# This file is for teaching kustomize how to substitute name and namespace reference in CRD
nameReference:
- kind: Service
version: v1
fieldSpecs:
- kind: CustomResourceDefinition
version: v1
group: apiextensions.k8s.io
path: spec/conversion/webhook/clientConfig/service/name
namespace:
- kind: CustomResourceDefinition
version: v1
group: apiextensions.k8s.io
path: spec/conversion/webhook/clientConfig/service/namespace
create: false
varReference:
- path: metadata/annotations
@@ -0,0 +1,7 @@
# The following patch adds a directive for certmanager to inject CA into the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
name: functions.sless.kube5s.ru
@@ -0,0 +1,7 @@
# The following patch adds a directive for certmanager to inject CA into the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
annotations:
cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
name: triggers.sless.kube5s.ru
@@ -0,0 +1,16 @@
# The following patch enables a conversion webhook for the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: functions.sless.kube5s.ru
spec:
conversion:
strategy: Webhook
webhook:
clientConfig:
service:
namespace: system
name: webhook-service
path: /convert
conversionReviewVersions:
- v1
@@ -0,0 +1,16 @@
# The following patch enables a conversion webhook for the CRD
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: triggers.sless.kube5s.ru
spec:
conversion:
strategy: Webhook
webhook:
clientConfig:
service:
namespace: system
name: webhook-service
path: /convert
conversionReviewVersions:
- v1
+72
View File
@@ -0,0 +1,72 @@
# Adds namespace to all resources.
namespace: sless-system
# Value of this field is prepended to the
# names of all resources, e.g. a deployment named
# "wordpress" becomes "alices-wordpress".
# Note that it should also match with the prefix (text before '-') of the namespace
# field above.
namePrefix: sless-
# Labels to add to all resources and selectors.
#commonLabels:
# someName: someValue
bases:
- ../crd
- ../rbac
- ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
#- ../webhook
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
#- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus
patchesStrategicMerge:
# Protect the /metrics endpoint by putting it behind auth.
# If you want your controller-manager to expose the /metrics
# endpoint w/o any authn/z, please comment the following line.
- manager_auth_proxy_patch.yaml
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in
# crd/kustomization.yaml
#- manager_webhook_patch.yaml
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'.
# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks.
# 'CERTMANAGER' needs to be enabled to use ca injection
#- webhookcainjection_patch.yaml
# the following config is for teaching kustomize how to do var substitution
vars:
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
#- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR
# objref:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert # this name should match the one in certificate.yaml
# fieldref:
# fieldpath: metadata.namespace
#- name: CERTIFICATE_NAME
# objref:
# kind: Certificate
# group: cert-manager.io
# version: v1
# name: serving-cert # this name should match the one in certificate.yaml
#- name: SERVICE_NAMESPACE # namespace of the service
# objref:
# kind: Service
# version: v1
# name: webhook-service
# fieldref:
# fieldpath: metadata.namespace
#- name: SERVICE_NAME
# objref:
# kind: Service
# version: v1
# name: webhook-service
@@ -0,0 +1,55 @@
# This patch inject a sidecar container which is a HTTP proxy for the
# controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews.
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
spec:
template:
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/arch
operator: In
values:
- amd64
- arm64
- ppc64le
- s390x
- key: kubernetes.io/os
operator: In
values:
- linux
containers:
- name: kube-rbac-proxy
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
image: gcr.io/kubebuilder/kube-rbac-proxy:v0.13.1
args:
- "--secure-listen-address=0.0.0.0:8443"
- "--upstream=http://127.0.0.1:8080/"
- "--logtostderr=true"
- "--v=0"
ports:
- containerPort: 8443
protocol: TCP
name: https
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 5m
memory: 64Mi
- name: manager
args:
- "--health-probe-bind-address=:8081"
- "--metrics-bind-address=127.0.0.1:8080"
- "--leader-elect"
+10
View File
@@ -0,0 +1,10 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
spec:
template:
spec:
containers:
- name: manager
+2
View File
@@ -0,0 +1,2 @@
resources:
- manager.yaml
+102
View File
@@ -0,0 +1,102 @@
apiVersion: v1
kind: Namespace
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: namespace
app.kubernetes.io/instance: system
app.kubernetes.io/component: manager
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: system
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: controller-manager
namespace: system
labels:
control-plane: controller-manager
app.kubernetes.io/name: deployment
app.kubernetes.io/instance: controller-manager
app.kubernetes.io/component: manager
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
spec:
selector:
matchLabels:
control-plane: controller-manager
replicas: 1
template:
metadata:
annotations:
kubectl.kubernetes.io/default-container: manager
labels:
control-plane: controller-manager
spec:
# TODO(user): Uncomment the following code to configure the nodeAffinity expression
# according to the platforms which are supported by your solution.
# It is considered best practice to support multiple architectures. You can
# build your manager image using the makefile target docker-buildx.
# affinity:
# nodeAffinity:
# requiredDuringSchedulingIgnoredDuringExecution:
# nodeSelectorTerms:
# - matchExpressions:
# - key: kubernetes.io/arch
# operator: In
# values:
# - amd64
# - arm64
# - ppc64le
# - s390x
# - key: kubernetes.io/os
# operator: In
# values:
# - linux
securityContext:
runAsNonRoot: true
# TODO(user): For common cases that do not require escalating privileges
# it is recommended to ensure that all your Pods/Containers are restrictive.
# More info: https://kubernetes.io/docs/concepts/security/pod-security-standards/#restricted
# Please uncomment the following code if your project does NOT have to work on old Kubernetes
# versions < 1.19 or on vendors versions which do NOT support this field by default (i.e. Openshift < 4.11 ).
# seccompProfile:
# type: RuntimeDefault
containers:
- command:
- /manager
args:
- --leader-elect
image: controller:latest
name: manager
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
# TODO(user): Configure the resources accordingly based on the project requirements.
# More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
resources:
limits:
cpu: 500m
memory: 128Mi
requests:
cpu: 10m
memory: 64Mi
serviceAccountName: controller-manager
terminationGracePeriodSeconds: 10
+27
View File
@@ -0,0 +1,27 @@
# These resources constitute the fully configured set of manifests
# used to generate the 'manifests/' directory in a bundle.
resources:
- bases/sless.clusterserviceversion.yaml
- ../default
- ../samples
- ../scorecard
# [WEBHOOK] To enable webhooks, uncomment all the sections with [WEBHOOK] prefix.
# Do NOT uncomment sections with prefix [CERTMANAGER], as OLM does not support cert-manager.
# These patches remove the unnecessary "cert" volume and its manager container volumeMount.
#patchesJson6902:
#- target:
# group: apps
# version: v1
# kind: Deployment
# name: controller-manager
# namespace: system
# patch: |-
# # Remove the manager container's "cert" volumeMount, since OLM will create and mount a set of certs.
# # Update the indices in this path if adding or removing containers/volumeMounts in the manager's Deployment.
# - op: remove
# path: /spec/template/spec/containers/1/volumeMounts/0
# # Remove the "cert" volume, since OLM will create and mount a set of certs.
# # Update the indices in this path if adding or removing volumes in the manager's Deployment.
# - op: remove
# path: /spec/template/spec/volumes/0
+2
View File
@@ -0,0 +1,2 @@
resources:
- monitor.yaml
+26
View File
@@ -0,0 +1,26 @@
# Prometheus Monitor Service (Metrics)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: servicemonitor
app.kubernetes.io/instance: controller-manager-metrics-monitor
app.kubernetes.io/component: metrics
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-monitor
namespace: system
spec:
endpoints:
- path: /metrics
port: https
scheme: https
bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
tlsConfig:
insecureSkipVerify: true
selector:
matchLabels:
control-plane: controller-manager
@@ -0,0 +1,16 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: metrics-reader
app.kubernetes.io/component: kube-rbac-proxy
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: metrics-reader
rules:
- nonResourceURLs:
- "/metrics"
verbs:
- get
+24
View File
@@ -0,0 +1,24 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: proxy-role
app.kubernetes.io/component: kube-rbac-proxy
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: proxy-role
rules:
- apiGroups:
- authentication.k8s.io
resources:
- tokenreviews
verbs:
- create
- apiGroups:
- authorization.k8s.io
resources:
- subjectaccessreviews
verbs:
- create
+19
View File
@@ -0,0 +1,19 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
app.kubernetes.io/name: clusterrolebinding
app.kubernetes.io/instance: proxy-rolebinding
app.kubernetes.io/component: kube-rbac-proxy
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: proxy-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: proxy-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+21
View File
@@ -0,0 +1,21 @@
apiVersion: v1
kind: Service
metadata:
labels:
control-plane: controller-manager
app.kubernetes.io/name: service
app.kubernetes.io/instance: controller-manager-metrics-service
app.kubernetes.io/component: kube-rbac-proxy
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: controller-manager-metrics-service
namespace: system
spec:
ports:
- name: https
port: 8443
protocol: TCP
targetPort: https
selector:
control-plane: controller-manager
+31
View File
@@ -0,0 +1,31 @@
# permissions for end users to edit functions.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: function-editor-role
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: function-editor-role
rules:
- apiGroups:
- sless.kube5s.ru
resources:
- functions
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- functions/status
verbs:
- get
+27
View File
@@ -0,0 +1,27 @@
# permissions for end users to view functions.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: function-viewer-role
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: function-viewer-role
rules:
- apiGroups:
- sless.kube5s.ru
resources:
- functions
verbs:
- get
- list
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- functions/status
verbs:
- get
+18
View File
@@ -0,0 +1,18 @@
resources:
# All RBAC will be applied under this service account in
# the deployment namespace. You may comment out this resource
# if your manager will use a service account that exists at
# runtime. Be sure to update RoleBinding and ClusterRoleBinding
# subjects if changing service account names.
- service_account.yaml
- role.yaml
- role_binding.yaml
- leader_election_role.yaml
- leader_election_role_binding.yaml
# Comment the following 4 lines if you want to disable
# the auth proxy (https://github.com/brancz/kube-rbac-proxy)
# which protects your /metrics endpoint.
- auth_proxy_service.yaml
- auth_proxy_role.yaml
- auth_proxy_role_binding.yaml
- auth_proxy_client_clusterrole.yaml
+44
View File
@@ -0,0 +1,44 @@
# permissions to do leader election.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
labels:
app.kubernetes.io/name: role
app.kubernetes.io/instance: leader-election-role
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: leader-election-role
rules:
- apiGroups:
- ""
resources:
- configmaps
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- coordination.k8s.io
resources:
- leases
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
@@ -0,0 +1,19 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
labels:
app.kubernetes.io/name: rolebinding
app.kubernetes.io/instance: leader-election-rolebinding
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: leader-election-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: leader-election-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+140
View File
@@ -0,0 +1,140 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: manager-role
rules:
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:
- namespaces
verbs:
- create
- get
- list
- watch
- apiGroups:
- ""
resources:
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- apps
resources:
- deployments
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- batch
resources:
- cronjobs
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- batch
resources:
- jobs
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- networking.k8s.io
resources:
- ingresses
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- functionjobs
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- functionjobs/finalizers
verbs:
- update
- apiGroups:
- sless.kube5s.ru
resources:
- functionjobs/status
verbs:
- get
- patch
- update
- apiGroups:
- sless.kube5s.ru
resources:
- functions
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- functions/finalizers
verbs:
- update
- apiGroups:
- sless.kube5s.ru
resources:
- functions/status
verbs:
- get
- patch
- update
- apiGroups:
- sless.kube5s.ru
resources:
- triggers/finalizers
verbs:
- update
+19
View File
@@ -0,0 +1,19 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
labels:
app.kubernetes.io/name: clusterrolebinding
app.kubernetes.io/instance: manager-rolebinding
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: manager-rolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: manager-role
subjects:
- kind: ServiceAccount
name: controller-manager
namespace: system
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: ServiceAccount
metadata:
labels:
app.kubernetes.io/name: serviceaccount
app.kubernetes.io/instance: controller-manager
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: controller-manager
namespace: system
+31
View File
@@ -0,0 +1,31 @@
# permissions for end users to edit triggers.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: trigger-editor-role
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: trigger-editor-role
rules:
- apiGroups:
- sless.kube5s.ru
resources:
- triggers
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- triggers/status
verbs:
- get
+27
View File
@@ -0,0 +1,27 @@
# permissions for end users to view triggers.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
labels:
app.kubernetes.io/name: clusterrole
app.kubernetes.io/instance: trigger-viewer-role
app.kubernetes.io/component: rbac
app.kubernetes.io/created-by: sless
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
name: trigger-viewer-role
rules:
- apiGroups:
- sless.kube5s.ru
resources:
- triggers
verbs:
- get
- list
- watch
- apiGroups:
- sless.kube5s.ru
resources:
- triggers/status
verbs:
- get
+5
View File
@@ -0,0 +1,5 @@
## Append samples you want in your CSV to this file as resources ##
resources:
- sless_v1alpha1_function.yaml
- sless_v1alpha1_trigger.yaml
#+kubebuilder:scaffold:manifestskustomizesamples
@@ -0,0 +1,12 @@
apiVersion: sless.kube5s.ru/v1alpha1
kind: Function
metadata:
labels:
app.kubernetes.io/name: function
app.kubernetes.io/instance: function-sample
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/created-by: sless
name: function-sample
spec:
# TODO(user): Add fields here
@@ -0,0 +1,12 @@
apiVersion: sless.kube5s.ru/v1alpha1
kind: Trigger
metadata:
labels:
app.kubernetes.io/name: trigger
app.kubernetes.io/instance: trigger-sample
app.kubernetes.io/part-of: sless
app.kubernetes.io/managed-by: kustomize
app.kubernetes.io/created-by: sless
name: trigger-sample
spec:
# TODO(user): Add fields here
+7
View File
@@ -0,0 +1,7 @@
apiVersion: scorecard.operatorframework.io/v1alpha3
kind: Configuration
metadata:
name: config
stages:
- parallel: true
tests: []
+16
View File
@@ -0,0 +1,16 @@
resources:
- bases/config.yaml
patchesJson6902:
- path: patches/basic.config.yaml
target:
group: scorecard.operatorframework.io
version: v1alpha3
kind: Configuration
name: config
- path: patches/olm.config.yaml
target:
group: scorecard.operatorframework.io
version: v1alpha3
kind: Configuration
name: config
#+kubebuilder:scaffold:patchesJson6902
@@ -0,0 +1,10 @@
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- basic-check-spec
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: basic
test: basic-check-spec-test
+50
View File
@@ -0,0 +1,50 @@
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- olm-bundle-validation
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: olm
test: olm-bundle-validation-test
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- olm-crds-have-validation
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: olm
test: olm-crds-have-validation-test
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- olm-crds-have-resources
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: olm
test: olm-crds-have-resources-test
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- olm-spec-descriptors
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: olm
test: olm-spec-descriptors-test
- op: add
path: /stages/0/tests/-
value:
entrypoint:
- scorecard-test
- olm-status-descriptors
image: quay.io/operator-framework/scorecard-test:v1.28.0
labels:
suite: olm
test: olm-status-descriptors-test
+366
View File
@@ -0,0 +1,366 @@
// Изменено: 2026-03-08
// FunctionReconciler — основной контроллер оператора.
// Следит за CRD Function и управляет lifecycle функции:
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
// Reconcile вызывается k8s при любом изменении Function объекта.
package controllers
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
)
// FunctionReconciler reconciles a Function object
type FunctionReconciler struct {
client.Client
Scheme *runtime.Scheme
Builder *builder.Builder
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;create
//+kubebuilder:rbac:groups="",resources=secrets,verbs=get;create
//+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
// Reconcile — главный цикл управления Function.
// Логика: читаем текущее состояние → определяем что нужно сделать → делаем.
func (r *FunctionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// Читаем Function объект из k8s
fn := &slessv1alpha1.Function{}
if err := r.Get(ctx, req.NamespacedName, fn); err != nil {
if errors.IsNotFound(err) {
// Объект удалён — ничего делать не надо, finalizer уже отработал
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get function: %w", err)
}
// Обрабатываем удаление через finalizer
if !fn.DeletionTimestamp.IsZero() {
return r.handleDeletion(ctx, fn)
}
// Добавляем finalizer при первом создании чтобы обработать удаление
if !containsString(fn.Finalizers, finalizerName) {
fn.Finalizers = append(fn.Finalizers, finalizerName)
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("add finalizer: %w", err)
}
return ctrl.Result{Requeue: true}, nil
}
// Есть новый код (s3Key изменился по сравнению с последним запуском сборки)
// и сборка сейчас не идёт — запускаем. Это единственное место где решается "нужна ли сборка".
// Почему аннотация а не phase: phase может быть Failed/Ready от прошлого кода;
// новый upload меняет Spec.S3Key → контроллер сам понимает что нужно пересобрать.
builtKey := fn.Annotations["sless.kube5s.ru/last-built-s3key"]
needsBuild := fn.Spec.S3Key != "" && builtKey != fn.Spec.S3Key
if needsBuild && fn.Status.Phase != slessv1alpha1.FunctionPhaseBuilding {
logger.Info("starting build", "function", fn.Name)
return r.startBuild(ctx, fn)
}
switch fn.Status.Phase {
case slessv1alpha1.FunctionPhaseBuilding:
return r.checkBuild(ctx, fn)
case slessv1alpha1.FunctionPhaseReady:
return r.ensureDeployment(ctx, fn)
}
return ctrl.Result{}, nil
}
const finalizerName = "sless.kube5s.ru/finalizer"
// startBuild запускает kaniko Job и помечает функцию как Building.
// Критически важно: СНАЧАЛА сохраняем last-built-s3key аннотацию, ПОТОМ status.
// Это предотвращает повторный запуск сборки при параллельных reconcile —
// следующий reconcile увидит last-built-s3key == spec.S3Key и не войдёт в startBuild.
func (r *FunctionReconciler) startBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
jobName, err := r.Builder.Build(ctx, fn.Namespace, fn.Name, fn.Spec.S3Key)
if err != nil {
return r.setFailed(ctx, fn, fmt.Sprintf("failed to start build: %v", err))
}
// Обновляем аннотации ПЕРВЫМИ — это idempotency guard.
// Как только last-built-s3key == spec.S3Key, дальнейшие reconcile не будут
// вызывать startBuild снова, даже если status ещё не обновился.
if fn.Annotations == nil {
fn.Annotations = map[string]string{}
}
fn.Annotations["sless.kube5s.ru/build-job"] = jobName
fn.Annotations["sless.kube5s.ru/last-built-s3key"] = fn.Spec.S3Key
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update build annotations: %w", err)
}
// Обновляем статус после аннотаций
fn.Status.Phase = slessv1alpha1.FunctionPhaseBuilding
fn.Status.Message = "Building image: " + jobName
if err := r.Status().Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update status to building: %w", err)
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// checkBuild проверяет статус build Job'а.
func (r *FunctionReconciler) checkBuild(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
jobName := fn.Annotations["sless.kube5s.ru/build-job"]
if jobName == "" {
// Аннотация потерялась — перезапускаем сборку
fn.Status.Phase = slessv1alpha1.FunctionPhasePending
_ = r.Status().Update(ctx, fn)
return ctrl.Result{Requeue: true}, nil
}
status, err := r.Builder.JobStatus(ctx, jobName)
if err != nil {
return ctrl.Result{}, fmt.Errorf("check build job: %w", err)
}
switch status {
case "running":
// Ещё идёт — проверим через 10 секунд
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
case "succeeded":
imageRef := r.Builder.ImageRef(fn.Namespace, fn.Name, fn.Spec.S3Key)
fn.Status.Phase = slessv1alpha1.FunctionPhaseReady
fn.Status.ImageRef = imageRef
fn.Status.Message = ""
now := metav1.Now()
fn.Status.LastBuiltAt = &now
if err := r.Status().Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update status to ready: %w", err)
}
// Чистим завершённый Job
_ = r.Builder.Cleanup(ctx, jobName)
return ctrl.Result{Requeue: true}, nil
case "failed":
return r.setFailed(ctx, fn, "build job failed")
}
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
// ensureDeployment создаёт или обновляет Deployment для HTTP функции.
// Deployment запускается в отдельном namespace sless-fn-{namespace}.
func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + fn.Namespace
// Создаём namespace для функций если не существует
ns := &corev1.Namespace{}
if err := r.Get(ctx, client.ObjectKey{Name: deployNS}, ns); err != nil {
if errors.IsNotFound(err) {
ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: deployNS}}
if err := r.Create(ctx, ns); err != nil {
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
}
}
// Обеспечиваем наличие registry pull-секрета в namespace функций.
// Без него kubelet не сможет pull-нуть private образ из Harbor.
if r.RegistrySecret != "" && r.OperatorNamespace != "" {
if err := r.ensureRegistrySecret(ctx, deployNS); err != nil {
// Не фатальная ошибка — логируем, но продолжаем
log.FromContext(ctx).Error(err, "failed to ensure registry secret", "ns", deployNS)
}
}
desired := r.buildDeployment(fn, deployNS)
existing := &appsv1.Deployment{}
err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existing)
if errors.IsNotFound(err) {
if err := r.Create(ctx, desired); err != nil {
return ctrl.Result{}, fmt.Errorf("create deployment: %w", err)
}
return ctrl.Result{}, nil
}
if err != nil {
return ctrl.Result{}, fmt.Errorf("get deployment: %w", err)
}
// Обновляем образ, env и imagePullSecrets при пересборке или изменении конфига.
// Тег образа уникален per build (sha256 от s3Key) → imagePullPolicy: IfNotPresent
// корректно подтягивает новый образ без дополнительных хаков.
// Env обновляем целиком — иначе изменение entrypoint/env_vars не применяется.
existing.Spec.Template.Spec.Containers[0].Image = fn.Status.ImageRef
existing.Spec.Template.Spec.Containers[0].Env = desired.Spec.Template.Spec.Containers[0].Env
existing.Spec.Template.Spec.ImagePullSecrets = desired.Spec.Template.Spec.ImagePullSecrets
if err := r.Update(ctx, existing); err != nil {
return ctrl.Result{}, fmt.Errorf("update deployment: %w", err)
}
return ctrl.Result{}, nil
}
// buildDeployment формирует Deployment манифест для функции.
func (r *FunctionReconciler) buildDeployment(fn *slessv1alpha1.Function, namespace string) *appsv1.Deployment {
replicas := int32(1)
envVars := []corev1.EnvVar{
// SLESS_ENTRYPOINT сообщает server.py/server.js какой файл и функцию загружать.
// Формат: "module-name.funcName" (например: handler-http.handle)
{Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint},
}
for k, v := range fn.Spec.Env {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
}
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: fn.Name,
Namespace: namespace,
Labels: map[string]string{"app": fn.Name, "managed-by": "sless"},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": fn.Name}},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": fn.Name}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: fn.Name,
Image: fn.Status.ImageRef,
Env: envVars,
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", fn.Spec.MemoryMB)),
},
},
},
},
ImagePullSecrets: func() []corev1.LocalObjectReference {
if r.RegistrySecret != "" {
return []corev1.LocalObjectReference{{Name: r.RegistrySecret}}
}
return nil
}(),
},
},
},
}
}
// ensureRegistrySecret копирует pull-секрет из namespace оператора в namespace функций.
// Вызывается при каждом reconcile — если секрет уже есть, ничего не делает.
func (r *FunctionReconciler) ensureRegistrySecret(ctx context.Context, targetNS string) error {
// Проверяем что секрет уже есть в целевом namespace
existing := &corev1.Secret{}
if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: targetNS}, existing); err == nil {
return nil // уже есть
} else if !errors.IsNotFound(err) {
return fmt.Errorf("check secret: %w", err)
}
// Копируем из namespace оператора
src := &corev1.Secret{}
if err := r.Get(ctx, client.ObjectKey{Name: r.RegistrySecret, Namespace: r.OperatorNamespace}, src); err != nil {
return fmt.Errorf("get source secret from %s: %w", r.OperatorNamespace, err)
}
copy := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: r.RegistrySecret,
Namespace: targetNS,
},
Type: src.Type,
Data: src.Data,
}
if err := r.Create(ctx, copy); err != nil {
if !errors.IsAlreadyExists(err) {
return fmt.Errorf("create secret in %s: %w", targetNS, err)
}
}
return nil
}
// handleDeletion обрабатывает удаление Function: удаляет Deployment, Service, Ingress и убирает finalizer.
func (r *FunctionReconciler) handleDeletion(ctx context.Context, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + fn.Namespace
dep := &appsv1.Deployment{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, dep); err == nil {
_ = r.Delete(ctx, dep)
}
// Удаляем Service и Ingress — созданы HTTP триггером, но именованы по функции.
// Если function_controller не удалит их, Ingress остаётся после destroy → 502.
svc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, svc); err == nil {
_ = r.Delete(ctx, svc)
}
ing := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, ing); err == nil {
_ = r.Delete(ctx, ing)
}
fn.Finalizers = removeString(fn.Finalizers, finalizerName)
if err := r.Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("remove finalizer: %w", err)
}
return ctrl.Result{}, nil
}
// setFailed переводит функцию в фазу Failed с сообщением об ошибке.
func (r *FunctionReconciler) setFailed(ctx context.Context, fn *slessv1alpha1.Function, msg string) (ctrl.Result, error) {
fn.Status.Phase = slessv1alpha1.FunctionPhaseFailed
fn.Status.Message = msg
if err := r.Status().Update(ctx, fn); err != nil {
return ctrl.Result{}, fmt.Errorf("update status to failed: %w", err)
}
return ctrl.Result{}, nil
}
func containsString(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func removeString(slice []string, s string) []string {
var result []string
for _, v := range slice {
if v != s {
result = append(result, v)
}
}
return result
}
// SetupWithManager sets up the controller with the Manager.
func (r *FunctionReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Function{}).
Complete(r)
}
+301
View File
@@ -0,0 +1,301 @@
// Изменено: 2026-03-09 (feature B: захват stdout пода Job в status.Message)
// FunctionJobReconciler — контроллер одноразовых запусков функций.
// При создании FunctionJob:
// 1. Ждёт пока Function станет Ready
// 2. Создаёт k8s Job который запускает образ функции с CMD runner
// 3. Следит за завершением Job → обновляет статус (Succeeded/Failed)
//
// Почему отдельный ресурс (не Trigger type=job):
// Trigger описывает постоянный способ вызова (http endpoint, cron schedule).
// FunctionJob — разовое событие с отдельным lifecycle и статусом результата.
package controllers
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// FunctionJobReconciler reconciles a FunctionJob object
type FunctionJobReconciler struct {
client.Client
Scheme *runtime.Scheme
RegistrySecret string // имя k8s Secret с docker credentials (для imagePullSecrets)
KubeClient kubernetes.Interface // typed client для чтения логов подов (logs API недоступен через controller-runtime client)
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functionjobs,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functionjobs/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functionjobs/finalizers,verbs=update
//+kubebuilder:rbac:groups=batch,resources=jobs,verbs=get;list;watch;create;update;patch;delete
// Reconcile — основной цикл контроллера.
func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
fj := &slessv1alpha1.FunctionJob{}
if err := r.Get(ctx, req.NamespacedName, fj); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get functionjob: %w", err)
}
// Уже завершён — ничего не делаем
if fj.Status.Phase == slessv1alpha1.FunctionJobPhaseSucceeded ||
fj.Status.Phase == slessv1alpha1.FunctionJobPhaseFailed {
return ctrl.Result{}, nil
}
// RunID=0 означает "не запускать".
// Позволяет создать FunctionJob в кластере, но запустить его явно позже
// (увеличив RunID > 0 через Terraform или kubectl patch).
if fj.Spec.RunID == 0 {
if fj.Status.Phase != "Skipped" {
fj.Status.Phase = "Skipped"
fj.Status.Message = "run_id=0: set run_id>0 to execute"
_ = r.Status().Update(ctx, fj)
}
return ctrl.Result{}, nil
}
// Проверяем что Function существует и готова
fn := &slessv1alpha1.Function{}
if err := r.Get(ctx, client.ObjectKey{Name: fj.Spec.FunctionRef, Namespace: fj.Namespace}, fn); err != nil {
if errors.IsNotFound(err) {
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
fj.Status.Message = "function not found: " + fj.Spec.FunctionRef
_ = r.Status().Update(ctx, fj)
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get function: %w", err)
}
if fn.Status.Phase != slessv1alpha1.FunctionPhaseReady {
fj.Status.Phase = slessv1alpha1.FunctionJobPhasePending
fj.Status.Message = "waiting for function Ready (current: " + string(fn.Status.Phase) + ")"
_ = r.Status().Update(ctx, fj)
// RequeueAfter: опрашиваем каждые 15с пока Function не станет Ready.
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
}
deployNS := "sless-fn-" + fj.Namespace
jobName := fmt.Sprintf("job-%s-%s", fj.Name, fj.CreationTimestamp.Format("20060102150405"))
// Если Job уже создан — проверяем его статус
existingJob := &batchv1.Job{}
if err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: deployNS}, existingJob); err == nil {
return r.syncJobStatus(ctx, fj, existingJob)
} else if !errors.IsNotFound(err) {
return ctrl.Result{}, fmt.Errorf("get job: %w", err)
}
// Создаём k8s Job
// Используем образ функции напрямую, переопределяем CMD чтобы запустить runner
// вместо server.py/server.js — runner выполняет handle(event) один раз и выходит
eventJSON := fj.Spec.EventJSON
if eventJSON == "" {
eventJSON = "{}"
}
// runner запускается через env var SLESS_EVENT — безопаснее чем передавать в args
// (args видны в ps aux, env vars — нет)
ttl := int32(600) // автоудаление Job через 10 мин после завершения
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: jobName,
Namespace: deployNS,
Labels: map[string]string{
"managed-by": "sless",
"functionjob": fj.Name,
"function": fn.Name,
},
},
Spec: batchv1.JobSpec{
// Не перезапускать при ошибке — это одноразовый запуск
BackoffLimit: int32Ptr(0),
// Автоудаление через 10 мин после завершения — чтобы не засорять кластер
TTLSecondsAfterFinished: &ttl,
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
// Используем тот же образ что и Deployment функции
// runner.py/runner.js переопределяет CMD сервера
InitContainers: nil,
Containers: []corev1.Container{
{
Name: "runner",
Image: fn.Status.ImageRef,
// Переопределяем точку входа: запускаем runner вместо server
// runner читает SLESS_EVENT и вызывает handle(event) один раз
Command: runtimeRunnerCommand(fn.Spec.Runtime),
Env: append(
fnEnvVars(fn),
corev1.EnvVar{
Name: "SLESS_EVENT",
Value: eventJSON,
},
),
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dMi", fn.Spec.MemoryMB)),
},
},
},
},
ImagePullSecrets: []corev1.LocalObjectReference{
{Name: r.RegistrySecret},
},
},
},
},
}
if err := r.Create(ctx, job); err != nil {
return ctrl.Result{}, fmt.Errorf("create job: %w", err)
}
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseRunning
fj.Status.JobName = jobName
fj.Status.StartTime = &now
fj.Status.Message = ""
if err := r.Status().Update(ctx, fj); err != nil {
return ctrl.Result{}, fmt.Errorf("update functionjob status: %w", err)
}
logger.Info("created job for functionjob", "job", jobName, "functionjob", fj.Name)
return ctrl.Result{}, nil
}
// syncJobStatus читает статус k8s Job и обновляет FunctionJob.Status.
// Если Job ещё выполняется — запрашивает повторный reconcile через 5 сек
// (Owns не работает кросс-неймспейсно, поэтому используем polling).
func (r *FunctionJobReconciler) syncJobStatus(ctx context.Context, fj *slessv1alpha1.FunctionJob, job *batchv1.Job) (ctrl.Result, error) {
if job.Status.Succeeded > 0 {
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseSucceeded
fj.Status.CompletionTime = &now
// Захватываем stdout пода — это return value функции (runner делает print(json.dumps(result)))
fj.Status.Message = getJobPodOutput(ctx, r.KubeClient, job.Namespace, job.Name)
} else if job.Status.Failed > 0 {
now := metav1.Now()
fj.Status.Phase = slessv1alpha1.FunctionJobPhaseFailed
fj.Status.CompletionTime = &now
fj.Status.Message = "job failed, check pod logs: kubectl logs -n sless-fn-" + fj.Namespace + " -l functionjob=" + fj.Name
} else {
// Job ещё выполняется — перечитаем через 5 секунд
if err := r.Status().Update(ctx, fj); err != nil {
return ctrl.Result{}, fmt.Errorf("sync job status: %w", err)
}
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
if err := r.Status().Update(ctx, fj); err != nil {
return ctrl.Result{}, fmt.Errorf("sync job status: %w", err)
}
return ctrl.Result{}, nil
}
// runtimeRunnerCommand возвращает CMD для запуска одноразового runner вместо HTTP-сервера.
// runner читает env SLESS_EVENT и SLESS_ENTRYPOINT, вызывает handle(event) один раз и завершается.
func runtimeRunnerCommand(runtime string) []string {
switch runtime {
case "nodejs20":
// inline runner — не требует отдельного файла в образе.
// SLESS_ENTRYPOINT="module.func": module=имя файла, func=экспортируемая функция
return []string{"node", "-e", `
const ep = process.env.SLESS_ENTRYPOINT || 'handler.handle';
const dot = ep.lastIndexOf('.');
const mod = ep.slice(0, dot >= 0 ? dot : ep.length);
const fn = dot >= 0 ? ep.slice(dot + 1) : 'handle';
const h = require('/app/function/' + mod);
const event = JSON.parse(process.env.SLESS_EVENT || '{}');
Promise.resolve(h[fn](event)).then(r => {
console.log(JSON.stringify(r));
process.exit(0);
}).catch(e => {
console.error(e.message);
process.exit(1);
});`}
default: // python3.11
return []string{"python3", "-c", `
import os, json, importlib.util
ep = os.environ.get("SLESS_ENTRYPOINT", "handler.handle")
dot = ep.rfind(".")
mod_name = ep[:dot] if dot >= 0 else ep
fn_name = ep[dot+1:] if dot >= 0 else "handle"
spec = importlib.util.spec_from_file_location(mod_name, "/app/function/" + mod_name + ".py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
event = json.loads(os.environ.get("SLESS_EVENT", "{}"))
result = getattr(mod, fn_name)(event)
print(json.dumps(result))
`}
}
}
// fnEnvVars преобразует env vars из FunctionSpec в k8s EnvVar slice.
// Включает SLESS_ENTRYPOINT чтобы runner.py/runner.js знал какую функцию вызывать.
func fnEnvVars(fn *slessv1alpha1.Function) []corev1.EnvVar {
result := []corev1.EnvVar{
{Name: "SLESS_ENTRYPOINT", Value: fn.Spec.Entrypoint},
}
for k, v := range fn.Spec.Env {
result = append(result, corev1.EnvVar{Name: k, Value: v})
}
return result
}
func int32Ptr(i int32) *int32 { return &i }
// getJobPodOutput находит под созданный Job-ом и возвращает его stdout (trimmed).
// runner.py/runner.js печатают json.dumps(result) в stdout — это и есть return value функции.
// Если под не найден или логи недоступны — возвращает "completed successfully" как fallback.
func getJobPodOutput(ctx context.Context, kube kubernetes.Interface, namespace, jobName string) string {
pods, err := kube.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "job-name=" + jobName,
})
if err != nil || len(pods.Items) == 0 {
return "completed successfully"
}
req := kube.CoreV1().Pods(namespace).GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{})
stream, err := req.Stream(ctx)
if err != nil {
return "completed successfully"
}
defer stream.Close()
buf := new(bytes.Buffer)
_, _ = io.Copy(buf, stream)
out := strings.TrimSpace(buf.String())
if out == "" {
return "completed successfully"
}
return out
}
// SetupWithManager регистрирует контроллер.
// Owns(&batchv1.Job{}) намеренно убрано: Job создаётся в другом namespace
// (sless-fn-*), где OwnerReference кросс-неймспейсно не работают.
// Вместо этого используется RequeueAfter-polling в syncJobStatus.
func (r *FunctionJobReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.FunctionJob{}).
Complete(r)
}
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package controllers
import (
"path/filepath"
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/envtest"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
//+kubebuilder:scaffold:imports
)
// These tests use Ginkgo (BDD-style Go testing framework). Refer to
// http://onsi.github.io/ginkgo/ to learn more about Ginkgo.
var cfg *rest.Config
var k8sClient client.Client
var testEnv *envtest.Environment
func TestAPIs(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Controller Suite")
}
var _ = BeforeSuite(func() {
logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))
By("bootstrapping test environment")
testEnv = &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")},
ErrorIfCRDPathMissing: true,
}
var err error
// cfg is defined in this file globally.
cfg, err = testEnv.Start()
Expect(err).NotTo(HaveOccurred())
Expect(cfg).NotTo(BeNil())
err = slessv1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
//+kubebuilder:scaffold:scheme
k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient).NotTo(BeNil())
})
var _ = AfterSuite(func() {
By("tearing down the test environment")
err := testEnv.Stop()
Expect(err).NotTo(HaveOccurred())
})
+320
View File
@@ -0,0 +1,320 @@
// Изменено: 2026-03-10
// TriggerReconciler — контроллер триггеров.
// HTTP триггер: создаёт Service + Ingress в namespace функции.
// Cron триггер: создаёт k8s CronJob который периодически вызывает функцию по внутреннему URL.
package controllers
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
netv1 "k8s.io/api/networking/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
)
// TriggerReconciler reconciles a Trigger object
type TriggerReconciler struct {
client.Client
Scheme *runtime.Scheme
IngressHost string // базовый домен для http триггеров (fallback): fn.kube5s.ru
// ExternalURL — если задан, URL функции = ExternalURL/fn/{namespace}/{name}
// (обходит wildcard DNS, трафик идёт через sless-api.kube5s.ru)
ExternalURL string
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=triggers/finalizers,verbs=update
//+kubebuilder:rbac:groups=networking.k8s.io,resources=ingresses,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
const triggerFinalizer = "sless.kube5s.ru/trigger-finalizer"
// Reconcile — управляет ресурсами для триггера в зависимости от его типа.
func (r *TriggerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
tr := &slessv1alpha1.Trigger{}
if err := r.Get(ctx, req.NamespacedName, tr); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get trigger: %w", err)
}
// Обработка удаления
if !tr.DeletionTimestamp.IsZero() {
return r.handleTriggerDeletion(ctx, tr)
}
// Добавляем finalizer если ещё нет
if !containsString(tr.Finalizers, triggerFinalizer) {
tr.Finalizers = append(tr.Finalizers, triggerFinalizer)
if err := r.Update(ctx, tr); err != nil {
return ctrl.Result{}, fmt.Errorf("add trigger finalizer: %w", err)
}
return ctrl.Result{Requeue: true}, nil
}
// Проверяем что Function существует и готова
fn := &slessv1alpha1.Function{}
if err := r.Get(ctx, client.ObjectKey{Name: tr.Spec.FunctionRef, Namespace: tr.Namespace}, fn); err != nil {
if errors.IsNotFound(err) {
tr.Status.Active = false
tr.Status.Message = "function not found: " + tr.Spec.FunctionRef
_ = r.Status().Update(ctx, tr)
return ctrl.Result{}, nil
}
return ctrl.Result{}, fmt.Errorf("get function: %w", err)
}
if fn.Status.Phase != slessv1alpha1.FunctionPhaseReady {
tr.Status.Active = false
tr.Status.Message = "waiting for function to be Ready (current: " + string(fn.Status.Phase) + ")"
_ = r.Status().Update(ctx, tr)
// RequeueAfter: опрашиваем каждые 15с пока Function не станет Ready.
// Watch на Function не добавлен, чтобы не усложнять контроллер на этом этапе.
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
}
// Управляем масштабом Deployment функции в зависимости от Enabled.
// Deployment находится в namespace sless-fn-{userNS}, имя = имя функции.
// Это позволяет "заморозить" функцию без удаления ресурса.
deployNS := "sless-fn-" + tr.Namespace
dep := &appsv1.Deployment{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, dep); err == nil {
var wantReplicas int32
if tr.Spec.Enabled {
wantReplicas = 1
} else {
wantReplicas = 0
}
// Обновляем только если значение изменилось, чтобы не создавать лишних событий
if dep.Spec.Replicas == nil || *dep.Spec.Replicas != wantReplicas {
dep.Spec.Replicas = &wantReplicas
if err := r.Update(ctx, dep); err != nil {
return ctrl.Result{}, fmt.Errorf("scale deployment replicas: %w", err)
}
logger.Info("scaled deployment", "function", fn.Name, "replicas", wantReplicas)
}
}
// Если триггер выключен — останавливаем reconcile здесь, ресурсы не создаём
if !tr.Spec.Enabled {
tr.Status.Active = false
tr.Status.Message = "disabled (enabled=false)"
_ = r.Status().Update(ctx, tr)
return ctrl.Result{}, nil
}
switch tr.Spec.Type {
case slessv1alpha1.TriggerTypeHTTP:
logger.Info("reconcile http trigger", "trigger", tr.Name)
return r.reconcileHTTP(ctx, tr, fn)
case slessv1alpha1.TriggerTypeCron:
logger.Info("reconcile cron trigger", "trigger", tr.Name)
return r.reconcileCron(ctx, tr, fn)
}
return ctrl.Result{}, nil
}
// reconcileHTTP создаёт Service для HTTP триггера.
// Если ExternalURL задан — URL = {ExternalURL}/fn/{namespace}/{name} (прокси через API).
// Иначе (fallback) — URL = https://{funcName}-{namespace}.{IngressHost} + создаёт Ingress.
func (r *TriggerReconciler) reconcileHTTP(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + tr.Namespace
// Service — направляет трафик к Deployment функции (нужен и для прокси, и для Ingress)
wantSvc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{Name: fn.Name, Namespace: deployNS},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": fn.Name},
Ports: []corev1.ServicePort{{Port: 8080, Protocol: corev1.ProtocolTCP}},
},
}
existingSvc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existingSvc); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantSvc); err != nil {
return ctrl.Result{}, fmt.Errorf("create service: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get service: %w", err)
}
}
var funcURL string
if r.ExternalURL != "" {
// Режим прокси через API сервер — не нужен wildcard DNS
funcURL = fmt.Sprintf("%s/fn/%s/%s", r.ExternalURL, tr.Namespace, fn.Name)
} else {
// Fallback: Ingress с поддоменом (требует wildcard DNS *.IngressHost)
host := fmt.Sprintf("%s-%s.%s", fn.Name, tr.Namespace, r.IngressHost)
pathType := netv1.PathTypePrefix
wantIng := &netv1.Ingress{
ObjectMeta: metav1.ObjectMeta{
Name: fn.Name,
Namespace: deployNS,
Annotations: map[string]string{
"kubernetes.io/ingress.class": "nginx",
},
},
Spec: netv1.IngressSpec{
Rules: []netv1.IngressRule{
{
Host: host,
IngressRuleValue: netv1.IngressRuleValue{
HTTP: &netv1.HTTPIngressRuleValue{
Paths: []netv1.HTTPIngressPath{
{
Path: "/",
PathType: &pathType,
Backend: netv1.IngressBackend{
Service: &netv1.IngressServiceBackend{
Name: fn.Name,
Port: netv1.ServiceBackendPort{Number: 8080},
},
},
},
},
},
},
},
},
},
}
existingIng := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, existingIng); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantIng); err != nil {
return ctrl.Result{}, fmt.Errorf("create ingress: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get ingress: %w", err)
}
}
funcURL = "https://" + host
}
tr.Status.Active = true
tr.Status.URL = funcURL
tr.Status.Message = ""
if err := r.Status().Update(ctx, tr); err != nil {
return ctrl.Result{}, fmt.Errorf("update trigger status: %w", err)
}
return ctrl.Result{}, nil
}
// reconcileCron создаёт CronJob который вызывает функцию по HTTP внутри кластера.
// curl делает POST на внутренний Service функции — это исключает внешний round-trip.
func (r *TriggerReconciler) reconcileCron(ctx context.Context, tr *slessv1alpha1.Trigger, fn *slessv1alpha1.Function) (ctrl.Result, error) {
deployNS := "sless-fn-" + tr.Namespace
// Внутренний URL: Service должен быть создан HTTP триггером или заранее
funcURL := fmt.Sprintf("http://%s.%s.svc.cluster.local:8080", fn.Name, deployNS)
wantCJ := &batchv1.CronJob{
ObjectMeta: metav1.ObjectMeta{
Name: tr.Name,
Namespace: tr.Namespace,
Labels: map[string]string{"managed-by": "sless", "trigger": tr.Name},
},
Spec: batchv1.CronJobSpec{
Schedule: tr.Spec.Schedule,
JobTemplate: batchv1.JobTemplateSpec{
Spec: batchv1.JobSpec{
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyOnFailure,
Containers: []corev1.Container{
{
// curlimages/curl вызывает функцию по внутреннему адресу
Name: "invoker",
Image: "curlimages/curl:latest",
Command: []string{"curl", "-sf", "-X", "POST", funcURL},
},
},
},
},
},
},
},
}
existing := &batchv1.CronJob{}
if err := r.Get(ctx, client.ObjectKey{Name: tr.Name, Namespace: tr.Namespace}, existing); err != nil {
if errors.IsNotFound(err) {
if err := r.Create(ctx, wantCJ); err != nil {
return ctrl.Result{}, fmt.Errorf("create cronjob: %w", err)
}
} else {
return ctrl.Result{}, fmt.Errorf("get cronjob: %w", err)
}
} else {
// Синхронизируем расписание если оно изменилось
existing.Spec.Schedule = tr.Spec.Schedule
if err := r.Update(ctx, existing); err != nil {
return ctrl.Result{}, fmt.Errorf("update cronjob schedule: %w", err)
}
}
tr.Status.Active = true
tr.Status.Message = ""
if err := r.Status().Update(ctx, tr); err != nil {
return ctrl.Result{}, fmt.Errorf("update trigger status: %w", err)
}
return ctrl.Result{}, nil
}
// handleTriggerDeletion удаляет ресурсы триггера и убирает finalizer.
func (r *TriggerReconciler) handleTriggerDeletion(ctx context.Context, tr *slessv1alpha1.Trigger) (ctrl.Result, error) {
if tr.Spec.Type == slessv1alpha1.TriggerTypeCron {
cj := &batchv1.CronJob{}
if err := r.Get(ctx, client.ObjectKey{Name: tr.Name, Namespace: tr.Namespace}, cj); err == nil {
_ = r.Delete(ctx, cj)
}
}
// Для HTTP триггеров удаляем Service и Ingress из sless-fn-{namespace}.
// Имена ресурсов совпадают с именем функции (FunctionRef).
// Без этого Ingress остаётся висеть после destroy и endpoint возвращает 502.
if tr.Spec.Type == slessv1alpha1.TriggerTypeHTTP {
deployNS := "sless-fn-" + tr.Namespace
fnName := tr.Spec.FunctionRef
svc := &corev1.Service{}
if err := r.Get(ctx, client.ObjectKey{Name: fnName, Namespace: deployNS}, svc); err == nil {
_ = r.Delete(ctx, svc)
}
ing := &netv1.Ingress{}
if err := r.Get(ctx, client.ObjectKey{Name: fnName, Namespace: deployNS}, ing); err == nil {
_ = r.Delete(ctx, ing)
}
}
tr.Finalizers = removeString(tr.Finalizers, triggerFinalizer)
if err := r.Update(ctx, tr); err != nil {
return ctrl.Result{}, fmt.Errorf("remove trigger finalizer: %w", err)
}
return ctrl.Result{}, nil
}
// SetupWithManager регистрирует контроллер.
// Watch на Function не добавлен — вместо этого используется RequeueAfter 15s.
// Это упрощает код на текущем этапе; при росте нагрузки заменить на Watches.
func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Trigger{}).
Complete(r)
}
+147
View File
@@ -0,0 +1,147 @@
# Изменено: 2026-03-07
# Деплой sless оператора в кластер.
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, docker auth)
# - Deployment: оператор naeel/sless-operator:v0.1.12 в namespace sless
# - Service: ClusterIP :9090 (REST API)
# - Ingress: sless-api.kube5s.ru → :9090 (внешний доступ с TLS)
#
# Перед применением:
# kubectl create secret generic sless-operator-secret \
# --namespace=sless \
# --from-literal=POSTGRES_DSN="..." \
# --from-literal=S3_ACCESS_KEY="..." \
# --from-literal=S3_SECRET_KEY="..." \
# --from-literal=SLESS_API_TOKEN="change-me" \
# --dry-run=client -o yaml | kubectl apply -f -
#
# Применение: kubectl apply -f deployments/k8s/operator.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
name: sless-operator-config
namespace: sless
data:
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
S3_BUCKET: "sless-functions"
S3_USE_SSL: "true"
REGISTRY_HOST: "naeel"
REGISTRY_SECRET: "sless-registry-auth"
API_PORT: "9090"
INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
# Позволяет обойтись без wildcard DNS *.fn.kube5s.ru
EXTERNAL_URL: "https://sless-api.kube5s.ru"
---
# Secret создаётся отдельно через kubectl (не коммитить секреты в git!)
# Описание ключей:
# POSTGRES_DSN — строка подключения к postgres
# S3_ACCESS_KEY — ключ доступа к S3/Ceph
# S3_SECRET_KEY — секретный ключ S3/Ceph
# SLESS_API_TOKEN — токен аутентификации API
#
# Пример создания:
# kubectl create secret generic sless-operator-secret -n sless \
# --from-literal=POSTGRES_DSN="postgres://sless:PASSWORD@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \
# --from-literal=S3_ACCESS_KEY="ACCESS_KEY" \
# --from-literal=S3_SECRET_KEY="SECRET_KEY" \
# --from-literal=SLESS_API_TOKEN="your-token-here"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: sless-operator
namespace: sless
labels:
app: sless-operator
spec:
replicas: 1
selector:
matchLabels:
app: sless-operator
template:
metadata:
labels:
app: sless-operator
spec:
serviceAccountName: sless-operator
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.12
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
- name: api
containerPort: 9090
- name: metrics
containerPort: 8080
- name: health
containerPort: 8081
envFrom:
- configMapRef:
name: sless-operator-config
- secretRef:
name: sless-operator-secret
readinessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: sless-operator
namespace: sless
spec:
selector:
app: sless-operator
ports:
- name: api
port: 9090
targetPort: 9090
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: sless-operator
namespace: sless
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: sless-api.kube5s.ru
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: sless-operator
port:
number: 9090
tls:
- hosts:
- sless-api.kube5s.ru
secretName: sless-operator-tls
+57
View File
@@ -0,0 +1,57 @@
# Изменено: 2026-03-07
# PostgreSQL для хранения логов вызовов функций.
# Для dev/test — без PersistentVolume, данные ephemeral.
# В prod заменить на managed PostgreSQL от провайдера.
---
apiVersion: v1
kind: Secret
metadata:
name: postgres-secret
namespace: sless
stringData:
POSTGRES_PASSWORD: "sless-pg-password"
POSTGRES_USER: "sless"
POSTGRES_DB: "sless"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: sless
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
envFrom:
- secretRef:
name: postgres-secret
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: sless
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432
+72
View File
@@ -0,0 +1,72 @@
# Изменено: 2026-03-09 (добавлен доступ к pods/log для feature B)
# RBAC для sless оператора.
# ServiceAccount + ClusterRole + ClusterRoleBinding.
# ClusterRole нужен (не namespaced Role) потому что оператор создаёт
# ресурсы в нескольких namespace (sless + sless-fn-*)
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: sless-operator
namespace: sless
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: sless-operator
rules:
# Наши CRD
- apiGroups: ["sless.kube5s.ru"]
resources: ["functions", "triggers", "functionjobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["sless.kube5s.ru"]
resources: ["functions/status", "triggers/status", "functionjobs/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["sless.kube5s.ru"]
resources: ["functions/finalizers", "triggers/finalizers", "functionjobs/finalizers"]
verbs: ["update"]
# Deployments для функций
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Jobs для kaniko builder
- apiGroups: ["batch"]
resources: ["jobs", "cronjobs"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Services + Ingress для HTTP триггеров
- apiGroups: [""]
resources: ["services", "namespaces"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Secrets — pull-секрет копируется в sless-fn-* namespace при создании функций
# list/watch нужны для controller-runtime cache (информер на secrets)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Events для operator-sdk
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
# Pods + logs — чтение stdout завершённых Job-подов (feature B: status.Message = return value функции)
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
# Lease для leader election
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: sless-operator
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: sless-operator
subjects:
- kind: ServiceAccount
name: sless-operator
namespace: sless
+39 -4
View File
@@ -3,7 +3,19 @@
## Базовый URL
```
https://sless.api.ngcloud.ru/v1
http://<operator-host>:9090/v1
```
Локально: `http://localhost:9090/v1`
В кластере (будущее): `http://sless-operator.sless.svc.cluster.local:9090/v1`
**Реализовано** (текущий namespace-aware формат):
```
/v1/namespaces/{namespace}/functions[/{name}]
/v1/namespaces/{namespace}/functions/{name}/upload
/v1/namespaces/{namespace}/triggers[/{name}]
/v1/namespaces/{namespace}/functions/{name}/invocations
```
## Аутентификация
@@ -49,11 +61,34 @@ Authorization: Bearer <cloud-token>
| 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)
- `go1.21`
- `python3.11`
- `nodejs20`
- `python3.11` — реализован и протестирован
- `go1.21` — планируется
- `nodejs20` — планируется
## Модель Function
@@ -0,0 +1,602 @@
# Подробный handoff для следующего агента
Последнее обновление: 2026-03-10
## Назначение документа
Этот документ нужен не для презентации проекта, а для практической передачи контекста следующему агенту.
Цель: после чтения файла должно быть понятно:
1. Что в проекте уже сделано и действительно работает.
2. Где архитектура сильная.
3. Где есть реальные дефекты, а где просто незавершённые v2-задачи.
4. Что нужно делать следующим шагом и в каком порядке.
5. Как проверять изменения с учётом ограничений среды (VPN, возможные TLS timeout).
Документ основан на анализе текущего кода, существующей документации и структуры проекта.
Сетевые E2E-проверки намеренно не делались в рамках этого разбора, потому что среда может шуметь из-за VPN и TLS timeout.
---
## Краткий вывод
Проект находится в хорошем состоянии для рабочего MVP managed serverless platform, но ещё не находится в состоянии production-grade managed cloud service.
Что важно понимать сразу:
1. Основа выбрана правильно: Kubernetes operator + CRD + build pipeline через S3 + kaniko.
2. Пользовательский путь уже частично доказан существующими Terraform examples и E2E заметками.
3. Главные проблемы сейчас не в том, что код "вообще не работает", а в том, что системные свойства платформы пока неполные: event lifecycle, observability, auth/tenancy, строгая валидация контрактов, тестовое покрытие.
Итоговая инженерная оценка:
1. Как технический фундамент для v1: хорошо.
2. Как demo/MVP для внутренней обкатки: хорошо.
3. Как настоящий managed cloud service: пока рано, есть несколько архитектурно значимых дыр.
---
## Что уже реализовано и выглядит здраво
### 1. Правильный control plane фундамент
Текущая модель сервиса строится через CRD и operator-подход:
1. Function описывает функцию и её runtime/config.
2. Trigger описывает способ вызова.
3. FunctionJob описывает одноразовый запуск.
Ключевые файлы:
1. api/v1alpha1/function_types.go
2. api/v1alpha1/trigger_types.go
3. api/v1alpha1/job_types.go
4. controllers/function_controller.go
5. controllers/trigger_controller.go
6. controllers/functionjob_controller.go
Это хорошее решение для системы, которая управляет lifecycle k8s-ресурсов, потому что:
1. Desired state вынесен в CRD.
2. Reconcile loop естественно ложится на build/deploy/cleanup.
3. Kubernetes остаётся источником правды по текущему фактическому состоянию ресурсов.
### 2. Build pipeline собран прагматично и без лишней магии
Фактически реализована понятная цепочка:
1. API принимает zip.
2. Upload handler генерирует Dockerfile и tar.gz контекст.
3. Контекст кладётся в S3.
4. Function controller запускает kaniko Job.
5. После успешной сборки создаётся/обновляется Deployment функции.
Ключевые файлы:
1. internal/api/handler/upload.go
2. internal/builder/builder.go
3. controllers/function_controller.go
Сильные стороны текущего решения:
1. Пользователь не думает про Dockerfile.
2. Сборка отделена от API и не делается внутри процесса оператора.
3. Используется idempotency guard через аннотацию last-built-s3key.
4. Используются version-like image refs через hash от S3 key, а не один общий latest для результата сборки.
### 3. API и provider уже образуют реальный пользовательский путь
Есть не только внутренние CRD, но и внешний контракт:
1. REST API на gorilla/mux.
2. Terraform provider в отдельном модуле.
3. Набор examples, которые уже реально гонялись.
Ключевые файлы:
1. internal/api/router.go
2. internal/api/handler/functions.go
3. internal/api/handler/triggers.go
4. internal/api/handler/jobs.go
5. terraform/provider/...
6. examples/...
Это важно: проект уже живёт не только как "внутренний оператор", а как зачаток полноценного managed продукта.
### 4. Видно, что проект уже проходил через реальные эксплуатационные проблемы
Это видно по документам:
1. doc/errors/log.md
2. doc/decisions/log.md
3. doc/progress.md
Плюс в том, что проект не застрял на happy path. Уже обнаружены и исправлялись:
1. бесконечные build loops,
2. cleanup после destroy,
3. cross-namespace проблемы с owner references,
4. image pull / registry проблемы,
5. несогласованность Terraform state.
Это хороший признак инженерной зрелости даже при сырой архитектуре.
---
## Текущая фактическая архитектура
### 1. Один бинарник совмещает operator manager и REST API
Файл: main.go
Что происходит:
1. Загружается env-конфиг.
2. Поднимается PostgreSQL store.
3. Выполняются миграции.
4. Поднимается S3 client.
5. Создаётся controller-runtime manager.
6. Регистрируются Function, Trigger и FunctionJob reconcilers.
7. Параллельно запускается HTTP API.
Это нормальное решение для раннего v1. Оно упрощает деплой и снижает количество moving parts.
Ограничение: по мере роста системы API и control plane логика будут мешать друг другу по масштабированию, отказам и ответственности. Но для текущей стадии это приемлемо.
### 2. Данные хранятся в двух разных источниках правды
Реально сейчас:
1. Состояние lifecycle функции и триггеров живёт в Kubernetes CRD/status.
2. Invocation history задумана в PostgreSQL.
3. Исходный код и build context живут в S3.
4. Docker image живёт во внешнем registry.
Это нормальная модель, но только если границы между источниками правды строго определены.
Сейчас эта модель задумана верно, но реализована не до конца, особенно вокруг invocation history.
### 3. HTTP вызов функции сейчас устроен через публичный прокси /fn/
Ключевые файлы:
1. internal/api/router.go
2. internal/api/handler/invoke.go
3. controllers/trigger_controller.go
Схема:
1. Trigger типа http формирует URL вида /fn/{namespace}/{name} через ExternalURL.
2. API-сервер принимает запрос.
3. InvokeFunction проксирует его во внутренний Service функции.
Для текущего окружения это разумный обход ограничения wildcard DNS.
Минус: это превращает API-процесс в data plane proxy для пользовательского трафика. Для MVP годится, для production это создаст узкое место и дополнительные требования к auth, rate limiting, tracing и HA.
---
## Подтверждённые проблемы и архитектурные долги
Ниже перечислены именно подтверждённые проблемы по текущему коду, а не абстрактные придирки.
### Критичность A — нужно чинить в ближайших итерациях
#### A1. TriggerReconciler рассчитывает на события Function, но реально не подписан на них
Файл: controllers/trigger_controller.go
Проблема:
1. В Reconcile есть ветка: если Function ещё не Ready, Trigger пишет статус waiting и выходит.
2. Комментарий говорит, что повторный reconcile придёт, когда Function изменится.
3. Но SetupWithManager регистрирует только For(&Trigger{}), без watch на Function.
Следствие:
1. Trigger, созданный раньше готовности Function, может застрять в промежуточном состоянии.
2. Пересчёт будет зависеть не от правильного события, а от случайного следующего изменения Trigger.
Почему это важно:
Для operator-системы это уже логический дефект event model, а не просто TODO.
Что делать:
1. Добавить watch на Function.
2. Обеспечить маппинг Function -> Trigger по namespace + FunctionRef.
3. Покрыть тестом сценарий "Trigger создан до готовности Function".
#### A2. FunctionJobReconciler имеет ту же проблему ожидания готовности Function
Файл: controllers/functionjob_controller.go
Проблема:
1. Если Function не Ready, FunctionJob уходит в Pending и выходит.
2. В комментарии подразумевается, что reconcile придёт позже.
3. Но SetupWithManager также подписан только на FunctionJob.
Следствие:
1. Job может зависнуть в Pending без события-пробуждения.
2. Фактическое выполнение зависит от внешнего изменения или ручного повторного reconcile.
Что делать:
1. Добавить watch на Function.
2. Либо ввести RequeueAfter polling для ожидания Function Ready.
3. Предпочтительнее watch, если можно аккуратно сматчить зависимости.
#### A3. Invocation history заявлена, но фактически не пишется
Файлы:
1. migrations/001_initial.sql
2. internal/storage/postgres/store.go
3. internal/api/handler/invocations.go
4. internal/api/handler/invoke.go
Подтверждённый факт:
1. Есть таблица invocations.
2. Есть SaveInvocation и ListInvocations.
3. Есть API-эндпоинт чтения истории.
4. В текущем коде нет места, где SaveInvocation реально вызывается.
Следствие:
1. API истории вызовов обещает поведение, которого фактически нет.
2. Документация и UX вводят в заблуждение: кажется, что история есть, но она пустая не потому, что вызовов не было, а потому что они не сохраняются.
Что делать:
1. Встроить запись инвокации в HTTP proxy path и, отдельно, в FunctionJob execution path.
2. Определить, что именно считается logs/result/status/duration.
3. Синхронизировать это решение с API design и examples.
#### A4. Multi-tenant модель ещё не реализована, а API уже строится вокруг namespace из URL
Файлы:
1. internal/api/router.go
2. internal/api/middleware/auth.go
3. doc/progress.md
4. doc/api/design.md
Проблема:
1. Namespace передаётся пользователем в URL.
2. Auth пока основан на одном статическом токене.
3. В документации прямо написано, что настоящая namespace isolation по токену ещё не сделана.
Следствие:
1. Контракт API пока demo-only.
2. При переходе к реальному tenant-aware auth почти наверняка придётся менять или сильно прятать namespace от клиента.
Что делать:
1. Зафиксировать как архитектурное решение: namespace выводится из identity, а не приходит от клиента.
2. Добавить whoami/identity resolution слой.
3. Планировать миграцию API аккуратно, пока пользователей мало.
### Критичность B — не ломает demo, но делает платформу хрупкой
#### B1. Важная часть конфигурации декларативна только на бумаге, но не используется последовательно
Файлы:
1. internal/config/config.go
2. main.go
3. controllers/function_controller.go
4. controllers/trigger_controller.go
5. controllers/functionjob_controller.go
Проблема:
1. Есть FunctionNamespacePrefix в конфиге.
2. Но контроллеры и wiring жёстко используют sless и sless-fn-.
Следствие:
1. Конфиг частично ложный: кажется, что поведение настраивается, но реально нет.
2. Перенос и переиспользование сервиса усложняются.
Что делать:
1. Либо реально протащить конфиг до всех мест использования.
2. Либо убрать мнимо-настраиваемое поле, если в v1 это сознательный hardcode.
#### B2. TimeoutSec описан в модели, но по сути не обеспечивается runtime-слоем
Файлы:
1. api/v1alpha1/function_types.go
2. internal/api/handler/functions.go
3. runtimes/python3.11/server.py
4. runtimes/nodejs20/server.js
Проблема:
1. Поле timeout есть в API/CRD.
2. Но runtime-обёртка не ограничивает время выполнения функции.
3. HTTP proxy timeout клиента не равен execution timeout функции как платформенной гарантии.
Следствие:
1. Контракт платформы частично ложный.
2. Длительный обработчик может вести себя непредсказуемо с точки зрения пользователя.
Что делать:
1. Либо честно задокументировать, что timeout пока informational.
2. Либо реализовать enforcement через runtime/process/k8s job semantics.
#### B3. Публичный /fn/ endpoint не отделён от control plane auth модели
Файлы:
1. internal/api/router.go
2. internal/api/middleware/auth.go
3. internal/api/handler/invoke.go
Проблема:
1. /v1 защищён статическим token.
2. /fn/ публичен.
3. Никакой tenant-aware authz на invoke path сейчас нет.
Для MVP это допустимо, но следующий агент должен понимать:
1. Это не production-ready security model.
2. Любая работа по auth должна учитывать отдельно control plane и data plane.
#### B4. Тестов ядра почти нет
Файлы:
1. controllers/suite_test.go
2. остальная кодовая база tests почти отсутствуют
Фактическое состояние:
1. Есть bootstrap envtest.
2. Нет содержательных тестов на reconcile lifecycle, cleanup, event propagation, API handler behaviour.
Следствие:
1. Реальная надёжность сейчас в основном держится на ручных E2E и Terraform examples.
2. Регрессии в controller logic будут ловиться поздно.
### Критичность C — скорее незавершённость и техдолг, чем немедленная поломка
#### C1. Статусная модель богаче реальной логики
Файлы:
1. api/v1alpha1/function_types.go
2. api/v1alpha1/trigger_types.go
Замечание:
1. Conditions объявлены, но не ведутся как системная модель статусов.
2. LastScheduleTime есть, но не видно логики обновления.
3. PreWarmSeconds есть, но отмечен как нереализованный.
Это не срочный баг, но следующий агент не должен тратить время, думая что вся эта модель уже рабочая.
#### C2. Cleanup build contexts и registry artefacts не выглядит завершённым
Замечание:
1. При удалении Function чистятся Deployment/Service/Ingress.
2. Не видно системного cleanup для S3 contexts и образов registry.
Для разработки допустимо.
Для managed среды это приведёт к накоплению мусора.
---
## Что не является проблемой само по себе
Следующий агент не должен тратить время на преждевременное "улучшательство".
### 1. Один бинарник для API и operator — нормально для текущей стадии
Не надо сейчас автоматически распиливать на микросервисы.
Это не даст пропорциональной пользы на текущем этапе.
### 2. Выбор gorilla/mux не является узким местом проекта
Главные риски проекта не в роутере и не в HTTP фреймворке.
### 3. Простые runtime wrappers — нормальны для v1
Python/Node runtime обёртки в текущем виде упрощают систему.
Их ограничения понятны, но они не являются главной ближайшей проблемой по сравнению с event model и invocation persistence.
---
## Приоритетный план работ для следующего агента
Ниже порядок работ, который имеет смысл соблюдать.
### Этап 1. Починить lifecycle зависимости Trigger и FunctionJob от Function
Цель:
1. Trigger и Job не должны зависать, если были созданы до готовности Function.
Задачи:
1. Добавить watch/mapping из Function в связанные Trigger.
2. Добавить watch/mapping из Function в связанные FunctionJob или явный polling для ожидания Ready.
3. Убедиться, что reconcile повторяется по правильным событиям, а не случайно.
Файлы-кандидаты:
1. controllers/trigger_controller.go
2. controllers/functionjob_controller.go
Критерии готовности:
1. Trigger, созданный раньше Function Ready, автоматически становится Active после готовности функции.
2. FunctionJob, созданный раньше Function Ready, сам продолжает lifecycle без ручного тычка.
3. Поведение покрыто тестами или хотя бы воспроизводимым локальным сценарием.
### Этап 2. Реально включить запись invocation history
Цель:
1. Сделать так, чтобы API истории вызовов соответствовал реальному поведению платформы.
Задачи:
1. На HTTP invoke path записывать status, duration, http_status и response/error.
2. На FunctionJob path записывать результат и статус отдельно как job-triggered invocation либо честно развести две сущности.
3. Обновить doc/api/design.md и doc/progress.md по факту.
Файлы-кандидаты:
1. internal/api/handler/invoke.go
2. internal/storage/postgres/store.go
3. controllers/functionjob_controller.go
4. internal/api/handler/invocations.go
Критерии готовности:
1. После успешного и неуспешного вызова в PostgreSQL появляется запись.
2. GET invocations возвращает не пустой декоративный список, а реальные записи.
### Этап 3. Синхронизировать контракт timeout/status/validation
Цель:
1. Убрать ложные обещания платформы и несогласованность API.
Задачи:
1. Решить, что означает TimeoutSec прямо сейчас.
2. Если не реализуется быстро, явно задокументировать ограничение.
3. Выправить Create/Update в functions.go и triggers.go так, чтобы значения по умолчанию и validation были последовательными.
Файлы-кандидаты:
1. internal/api/handler/functions.go
2. internal/api/handler/triggers.go
3. api/v1alpha1/function_types.go
4. doc/api/design.md
Критерии готовности:
1. API не заставляет клиента задавать поля, которые CRD должен дефолтить.
2. Update не может случайно испортить spec нулевыми значениями.
3. Документация не обещает больше, чем реально реализовано.
### Этап 4. Уменьшить ложную конфигурируемость
Цель:
1. Либо сделать конфиг реальным, либо убрать фиктивную настраиваемость.
Задачи:
1. Разобраться с FunctionNamespacePrefix и хардкодами sless/sless-fn-.
2. Принять одно из двух решений:
а) протащить конфиг через контроллеры и main;
б) удалить поле до тех пор, пока оно реально не нужно.
Файлы-кандидаты:
1. internal/config/config.go
2. main.go
3. controllers/function_controller.go
4. controllers/trigger_controller.go
5. controllers/functionjob_controller.go
### Этап 5. Добавить минимально полезные автоматические тесты
Цель:
1. Снизить зависимость от ручных прогонов.
Минимальный полезный набор:
1. Trigger ждёт Function Ready и потом активируется.
2. Function deletion чистит дочерние ресурсы.
3. FunctionJob не зависает в Pending/Running навсегда.
4. Invoke path корректно пишет invocation history.
Важно:
Не надо начинать с больших e2e по сети.
Нужны локальные, детерминированные, дешёвые проверки.
---
## Как проверять изменения в этой среде
С учётом замечания пользователя про VPN и возможные TLS timeout, стратегия проверки должна быть осторожной.
### Что предпочитать
1. Локальные Go-тесты.
2. envtest/контроллерные тесты без внешней сети.
3. Анализ кода и существующих E2E результатов в examples/ и doc/progress.md.
### Что не делать первым шагом
1. Не начинать с внешних curl/HTTPS smoke test.
2. Не считать TLS timeout автоматическим подтверждением бага в сервисе.
3. Не делать выводы по сетевому шуму без локального подтверждения.
### Практический порядок проверки
1. Сначала локальные unit/envtest проверки.
2. Потом, если нужно, ограниченные сценарии через provider/examples.
3. Только в самом конце сетевые end-to-end через ingress.
---
## Быстрые ориентиры по файлам
Если следующему агенту нужно быстро войти в проект, читать в таком порядке:
1. main.go
2. api/v1alpha1/function_types.go
3. api/v1alpha1/trigger_types.go
4. api/v1alpha1/job_types.go
5. controllers/function_controller.go
6. controllers/trigger_controller.go
7. controllers/functionjob_controller.go
8. internal/api/router.go
9. internal/api/handler/upload.go
10. internal/api/handler/invoke.go
11. internal/storage/postgres/store.go
12. doc/progress.md
13. doc/errors/log.md
14. doc/decisions/log.md
---
## Короткий practical summary для следующего агента
Если нужно запомнить только главное:
1. Не рефакторить всё подряд. Основа проекта нормальная.
2. Первые реальные проблемы: event model Trigger/FunctionJob и отсутствие записи invocation history.
3. Не путать v2-идеи с текущими дефектами: не всё незавершённое надо чинить прямо сейчас.
4. Не полагаться в первую очередь на внешнюю сеть и TLS smoke test: среда может шуметь.
5. Любые значимые изменения обязательно фиксировать в doc/progress.md и, при необходимости, в doc/errors/log.md или doc/decisions/log.md.
---
## Рекомендуемое следующее действие
Самый рациональный следующий шаг для нового агента:
1. Сфокусироваться на TriggerReconciler и FunctionJobReconciler.
2. Починить повторное пробуждение от изменений Function.
3. Добавить под это минимальные локальные тесты.
Это даст максимальный выигрыш в надёжности control plane без лишнего расширения функциональности.
@@ -0,0 +1,613 @@
# Claude Opus 4.6: Прагматичный технический обзор sless
Дата: 2026-03-10
Автор: Claude Opus 4.6
Контекст: Полный анализ кодовой базы sless + ревью документов GPT-5.4 и Claude Sonnet 4.6
---
## Преамбула: для кого этот документ
Этот документ написан для **небольшого облачного провайдера** (nubes.ru), а не для Amazon или Yandex Cloud.
Это значит:
- Пользователей пока единицы, не тысячи.
- Сервис — одна из многих фич облака, не ключевой revenue stream.
- Команда маленькая, времени мало, каждый шаг должен давать реальный выход.
- Перфекционизм — враг. Прагматизм — друг.
Все рекомендации ниже даны с учётом этого. Я не буду предлагать gVisor, LLM-validation, image scanning и прочие production-grade вещи на текущем этапе. Подробнее — в разделе "Где я не согласен с коллегами".
---
## Executive Summary
**Общая оценка: 7/10 для текущей стадии.**
Проект **реально работает**. Это главное. Есть:
- Полный цикл: код → zip → S3 → kaniko → Docker image → Deployment → HTTP endpoint
- Два рантайма (Python 3.11, Node.js 20), оба протестированы
- Terraform provider с source_dir, WaitReady, WaitGone
- Набор примеров которые работают (hello-node, simple-node, simple-python, notes-python)
- Cron триггеры, FunctionJob для разовых запусков
- Механизмы lifecycle control (enabled, run_id)
Проект прошёл через **реальные** проблемы (бесконечные build-циклы, cross-namespace OwnerRef, cleanup bugs, registry нестабильность) и **решил** их все. Это важнее чем идеальная архитектура.
**Но есть конкретные дефекты, которые реально сломают сервис при первых же пользователях.** Ниже — что именно и в каком порядке чинить.
---
## Часть 1: Подтверждённые дефекты — что реально ломается
### 1.1 Trigger и FunctionJob не реагируют на готовность Function
**Что происходит:**
```go
// trigger_controller.go, строка ~313
func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Trigger{}). // Только Trigger. Нет watch на Function.
Complete(r)
}
```
Комментарий в коде (строка ~311) говорит: "настраивает watch на Function". **Это неправда** — watch на Function отсутствует.
**Когда стреляет:** Terraform создаёт Function и Trigger одновременно (или Trigger чуть раньше). Trigger проверяет Function, видит "не Ready", пишет "waiting" и **выходит без RequeueAfter**. Когда Function станет Ready — Trigger **не узнает об этом**. Trigger может зависнуть навсегда.
**Аналогичная проблема** в `functionjob_controller.go` — FunctionJob тоже не подписан на изменения Function.
**Реальный сценарий:** Пользователь делает `terraform apply` с Function + Trigger + Job. Kaniko собирает образ 1-2 минуты. Trigger создаётся за секунды. Trigger застревает в "waiting", пользователь думает что платформа сломалась.
**Почему это работает сейчас в demo:** Terraform provider делает WaitReady на Function перед созданием Trigger. Но прямое создание через kubectl или API — уязвимо.
**Как чинить:**
Вариант A (правильный): Добавить `Watches` на Function с маппингом на связанные Trigger/FunctionJob:
```go
func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Trigger{}).
Watches(
&slessv1alpha1.Function{},
handler.EnqueueRequestsFromMapFunc(r.findTriggersForFunction),
).
Complete(r)
}
```
Вариант B (простой, но грубый): Добавить `RequeueAfter: 15 * time.Second` когда Function не Ready. Trigger будет переопрашивать каждые 15 секунд. Грубо, но работает и реализуется за 2 строки.
**Рекомендация для небольшого провайдера:** Вариант B сейчас, Вариант A — когда появляется хотя бы 10 активных пользователей. RequeueAfter для единиц пользователей вообще не создаёт нагрузки.
**Оценка трудозатрат:** Вариант B — 15 минут. Вариант A — 1-2 часа.
---
### 1.2 Invocation history обещана, но не пишется
**Факт:** Есть таблица `invocations` в PostgreSQL. Есть метод `SaveInvocation()`. Есть API endpoint GET. Метод **нигде не вызывается**.
**Файлы:**
- `migrations/001_initial.sql` — таблица есть
- `internal/storage/postgres/store.go` — SaveInvocation реализован
- `internal/api/handler/invocations.go` — ListInvocations вызывается
- `internal/api/handler/invoke.go` — SaveInvocation **не вызывается**
**Когда стреляет:** Пользователь вызывает GET /invocations — всегда пустой список. Он думает что вызовов не было, а они были.
**Как чинить:**
В `invoke.go`, после получения ответа от функции, вызвать `SaveInvocation`:
```go
// После resp, err := httpClient.Do(proxyReq)
go func() {
_ = h.Store.SaveInvocation(context.Background(), &postgres.Invocation{
FunctionName: name,
Namespace: ns,
HTTPStatus: resp.StatusCode,
Duration: duration,
CreatedAt: time.Now(),
})
}()
```
**Важно:** Запись в горутине чтобы не замедлять HTTP response. Если Postgres упал — вызов всё равно прошёл, просто не записался. Это нормально для логов, не для критических данных.
**Альтернатива:** Если invocation history пока никому не нужна — **честно убрать endpoint** из API. Пустой API хуже отсутствующего — он вводит в заблуждение.
**Рекомендация:** Для началa убрать endpoint или поставить заглушку с 501 Not Implemented. Когда реально понадобится — тогда реализовать нормально с учётом того что писать (статус, duration, размер ответа, ошибка).
**Оценка трудозатрат:** Заглушка — 5 минут. Реализация — 1-2 часа.
---
### 1.3 UpdateFunction молча затирает spec нулевыми значениями
**Что происходит:**
```go
// functions.go, UpdateFunction
fn.Spec.Runtime = req.Runtime // если req.Runtime == "" → затрёт ""
fn.Spec.Entrypoint = req.Entrypoint // аналогично
fn.Spec.MemoryMB = req.MemoryMB // если не передал → 0, но Create требует >0
fn.Spec.TimeoutSec = req.TimeoutSec // если не передал → 0
```
**Когда стреляет:** Пользователь хочет обновить только `env_vars`. Отправляет `{"env_vars": {"DB": "..."}}`. Все остальные поля в req = zero values. MemoryMB становится 0. Runtime становится "". Deployment ломается.
**Как чинить:**
Два варианта:
1. **PATCH-семантика:** обновлять только ненулевые поля (JSON merge patch)
2. **Полная замена:** требовать все поля как в Create (PUT-семантика с валидацией)
**Рекомендация:** PUT с валидацией — проще и надёжнее. Добавить ту же проверку что в Create:
```go
if req.Runtime == "" || req.Entrypoint == "" || req.MemoryMB <= 0 {
writeJSON(w, http.StatusBadRequest, errResp("runtime, entrypoint and memory_mb are required"))
return
}
```
**Оценка трудозатрат:** 15 минут.
---
### 1.4 CronJob использует `curlimages/curl:latest` с внешнего DockerHub
```go
// trigger_controller.go, reconcileCron
Image: "curlimages/curl:latest",
```
**Проблемы:**
1. `:latest` — непредсказуемый тег (вредности этого уже была уроком на этом же проекте)
2. Внешняя зависимость — если DockerHub rate-limit или сеть мигнула, cron-триггер не работает
3. Минорная: не pinned к дайджесту
**Как чинить:** Запинить версию: `curlimages/curl:8.5.0` (или какая стабильная). Или использовать `busybox:1.36` с `wget`.
**Оценка трудозатрат:** 2 минуты.
---
## Часть 2: Несогласованность контрактов — не ломает, но путает
### 2.1 FunctionNamespacePrefix объявлен, но не используется
**Файлы:**
- `internal/config/config.go` — поле `FunctionNamespacePrefix` из env `FUNCTION_NAMESPACE_PREFIX`
- `controllers/function_controller.go` — хардкод `"sless-fn-" + fn.Namespace`
- `controllers/trigger_controller.go` — хардкод `"sless-fn-" + tr.Namespace`
- `controllers/functionjob_controller.go` — хардкод `"sless-fn-" + fj.Namespace`
- `internal/api/handler/invoke.go` — хардкод `sless-fn-%s`
Конфиг-поле создаёт иллюзию настраиваемости. Если кто-то изменит env — ничего не произойдёт.
**Рекомендация:** Удалить поле из конфига. Хардкод `sless-fn-` это нормально для v1. Если когда-нибудь понадобится менять — тогда и протащить. Сейчас мёртвый конфиг хуже хардкода.
**Оценка трудозатрат:** 10 минут (удалить поле + обновить комментарий).
---
### 2.2 TimeoutSec: есть в CRD и API, не enforcement нигде
**Файлы:**
- `api/v1alpha1/function_types.go` — поле `TimeoutSec int32`
- `internal/api/handler/functions.go` — принимается в API
- `runtimes/python3.11/server.py` — **нет** ограничения по времени
- `runtimes/nodejs20/server.js` — **нет** ограничения по времени
- `internal/api/handler/invoke.go` — `httpClient.Timeout = 30 * time.Second` (статический, не из CRD)
**Рекомендация:** **Не реализовывать** timeout enforcement сейчас. Это потребует:
- Передать TimeoutSec через env var в pod
- Реализовать в каждом runtime (process timeout, signal handling)
- Синхронизировать proxy timeout с pod timeout
Это значимая работа. Для единиц пользователей — overkill.
Вместо этого: **задокументировать** в API design что TimeoutSec сейчас informational. Когда появятся реальные long-running проблемы — тогда реализовать.
---
### 2.3 Conditions в CRD объявлены, но не ведутся
```go
// function_types.go
Conditions []metav1.Condition `json:"conditions,omitempty"`
```
Код обновляет `Phase` и `Message`, но Conditions не заполняет — это мёртвый код в status.
**Рекомендация:** Не трогать. Conditions — стандартная практика k8s, они пригодятся позже. Пока Phase+Message достаточно. Главное — не строить на них зависимости в контроллере пока не заполняете.
---
### 2.4 PreWarmSeconds — объявлен, не реализован
```go
// trigger_types.go
PreWarmSeconds int32 `json:"preWarmSeconds,omitempty"`
```
**Рекомендация:** Оставить как есть. Это заготовка для scale-to-zero. Пока нет scale-to-zero — поле бесполезно, но и не мешает.
---
## Часть 3: Что реально работает хорошо
Прежде чем обсуждать проблемы, важно зафиксировать что сделано правильно. Это не "похвала", а правки в оценку рисков: если фундамент плохой — надо переделывать. Фундамент **хороший**.
### 3.1 Идемпотентность сборки
```go
builtKey := fn.Annotations["sless.kube5s.ru/last-built-s3key"]
needsBuild := fn.Spec.S3Key != "" && builtKey != fn.Spec.S3Key
```
Решение с аннотацией `last-built-s3key` как idempotency guard — **отлично**. Это решило реальную проблему (94 параллельных Job-а). Решение простое, понятное, работает. Не надо менять.
### 3.2 Версионированные теги образов
Переход от `:latest` к version-based тегам (через hash от S3 key) — правильное решение.
Это решило проблему кэширования kaniko и `imagePullPolicy: IfNotPresent`.
### 3.3 MergePatch вместо Update в upload.go
```go
patch := client.MergeFrom(fn.DeepCopy())
fn.Spec.S3Key = s3Key
h.K8s.Patch(ctx, fn, patch)
```
Правильно. Прямой `Update` ломался из-за concurrent reconcile. MergePatch — стандартное решение.
### 3.4 source_dir в Terraform provider
Убрали зависимость от `hashicorp/archive` — это устранило VPN/registry конфликт. Provider сам создаёт zip, считает SHA256. Независимость от внешних провайдеров = меньше точек отказа.
### 3.5 Cleanup через finalizer
Function, Trigger — корректная схема cleanup через finalizer. handleDeletion чистит Deployment + Service + Ingress. handleTriggerDeletion чистит CronJob/Service/Ingress. Terraform provider ждёт WaitGone.
### 3.6 Решение с cross-namespace polling
Проблема: OwnerReference кросс-неймспейсно не работают → Owns(&Job{}) бесполезен.
Решение: убрать Owns, использовать RequeueAfter polling.
Это грубо, но работает и задокументировано в коде с пояснением почему.
### 3.7 Документация ошибок
`doc/errors/log.md` — **отличная** инженерная практика. Каждая проблема с причиной, контекстом, решением. Это ценнее тестов на данном этапе, потому что помогает не наступать на те же грабли.
### 3.8 Один бинарник — правильно для текущего масштаба
Один процесс = один Deployment, один лог, одна точка мониторинга. Для 1-50 функций в кластере — это идеальное решение. Не надо распиливать.
---
## Часть 4: Где я не согласен с коллегами
### 4.1 С GPT-5.4 (agent-handoff-2026-03-10.md)
GPT-5.4 провёл качественный технический аудит. Проблемы A1-A4 выявлены точно. Приоритизация разумная. **Но:**
**Избыточный фокус на config consistency (B1).** GPT-5.4 поставил "убрать FunctionNamespacePrefix" в отдельный этап работ (Этап 4) — это работа на 10 минут, не заслуживает целого этапа. Просто удалить поле заодно при следующем коммите.
**Недооценка проблемы UpdateFunction.** GPT-5.4 упоминает "Update не может случайно испортить spec" в этапе 3 (validation), но не выделяет как отдельную проблему. Между тем это реально может сломать функцию при первом же `terraform apply` с частичным обновлением.
**В целом:** GPT-5.4 дал отличную базу. Его 5 этапов — разумный план. Я согласен с приоритизацией примерно на 80%.
### 4.2 С Claude Sonnet 4.6 (sonnet-review-of-gpt-analysis.md)
Sonnet выявил что GPT-5.4 "смотрел как на внутренний инструмент". Это верное наблюдение. **Но затем Sonnet ушёл в другую крайность — начал проектировать production security для Amazon-масштаба.**
Конкретно:
**gVisor (RuntimeClass)** — для небольшого облачного провайдера с единицами пользователей это **overkill**. gVisor:
- Требует установки на каждую ноду кластера
- Даёт 10-20% performance overhead
- Ломает некоторые syscalls (не все рантаймы работают)
- Усложняет debugging
- Решает проблему container escape, которая актуальна когда у вас **тысячи** непроверенных пользователей
**Когда нужен gVisor:** Когда сервис открыт для self-service публичной регистрации. Для invite-only или managed-клиентов небольшого провайдера — NetworkPolicy между namespace достаточно на годы.
**LLM-валидация кода** — интересная идея, но:
- False positives будут ломать developer experience
- Задержка сборки +5-15 секунд на каждый деплой
- LLM не гарантирует detection rate для malware
- Сложно тестировать и поддерживать
- Bandit/npm audit покрывают 80% реальных проблем без LLM
**Когда нужен LLM:** Когда у вас free tier с тысячами анонимных пользователей. Не сейчас.
**Image scanning (Trivy/Grype)** — полезная вещь, но:
- Сканирует base image, не пользовательский код
- Можно запустить один раз при обновлении runtime, не при каждом build
- Не требует интеграции в pipeline
**ResourceQuota per tenant** — вот это **полезно** и **просто**:
```yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: sless-quota
namespace: sless-fn-{tenant}
spec:
hard:
pods: "20"
requests.memory: 4Gi
```
Это единственная security-рекомендация Sonnet которую стоит реализовать сейчас. Защищает от fork-бомб и runaway pods без сложностей.
**NetworkPolicy** — да, стоит добавить. Но простейшую — deny inter-namespace traffic. Не сложную multi-rule систему.
**Моя оценка Sonnet:** Правильно указал на пробел в security-мышлении. Но рекомендации масштабированы для Amazon, не для nubes.ru. Из его 8-пунктного плана для текущего этапа актуальны только 2: NetworkPolicy (простая) и ResourceQuota.
---
## Часть 5: Конкретный план работ — что делать и в каком порядке
Порядок отсортирован по **отдаче на вложенное время**. Не по "правильности" или "красоте".
### Этап 0: Быстрые фиксы (1-2 часа суммарно)
Эти вещи можно и нужно сделать прямо сейчас, одним коммитом:
| # | Задача | Время | Файлы |
|---|--------|-------|-------|
| 1 | RequeueAfter: 15s когда Function не Ready в TriggerReconciler | 2 мин | `controllers/trigger_controller.go` строка ~87 |
| 2 | RequeueAfter: 15s когда Function не Ready в FunctionJobReconciler | 2 мин | `controllers/functionjob_controller.go` строка ~111 |
| 3 | Валидация в UpdateFunction (runtime, entrypoint, memory_mb required) | 15 мин | `internal/api/handler/functions.go` |
| 4 | Pinned version curl image: `curlimages/curl:8.5.0` | 2 мин | `controllers/trigger_controller.go` |
| 5 | Убрать FunctionNamespacePrefix из config.go | 10 мин | `internal/config/config.go` |
| 6 | Invocations endpoint → 501 Not Implemented ИЛИ убрать из API | 10 мин | `internal/api/handler/invocations.go`, `internal/api/router.go` |
**Почему этап 0:** Каждый из этих фиксов занимает минуты, но закрывает реальный дефект или убирает ложный контракт. Суммарно — 1 час максимум, а сервис становится значительно надёжнее.
### Этап 1: Реализация invocation history (если нужна) — 2-4 часа
**Только если** есть реальный use case для истории вызовов (биллинг, дебаг, мониторинг).
Если нет — **пропустить**. Пустой endpoint хуже отсутствующего, но 501 из этапа 0 честно говорит "не реализовано".
Если нужна:
1. Добавить SaveInvocation в invoke.go (асинхронно, в горутине)
2. Добавить запись результата в FunctionJobReconciler при завершении Job
3. Протестировать через curl + GET /invocations
4. Обновить doc/api/design.md
**Файлы:**
- `internal/api/handler/invoke.go`
- `controllers/functionjob_controller.go`
- `internal/storage/postgres/store.go`
- `internal/api/handler/invocations.go`
### Этап 2: ResourceQuota + NetworkPolicy — 1-2 часа
Минимальная изоляция tenantов. Даже для единиц пользователей это разумная гигиена.
### Этап 2.5: LLM-валидация кода при upload — 3-4 часа
**Обновлено:** Перенесено из "когда-нибудь потом" в активный план.
**Причина:** LLM уже в облаке nubes.ru — это dogfooding + маркетинг + реальная загрузка сервиса работой.
Детали дизайна: `doc/decisions/log.md` → "2026-03-10 — LLM-валидация кода при upload".
**Суть:** Новый пакет `internal/validator/`. Между распаковкой zip и отправкой в S3 — вызов LLM API.
Safe=false → HTTP 400. LLM недоступен → soft-fail (warning, деплой проходит).
Выключается через `LLM_ENABLED=false` (default).
**Файлы:**
1. `internal/validator/validator.go` — интерфейс + NoopValidator
2. `internal/validator/llm.go` — LLM HTTP client
3. `internal/validator/extract.go` — извлечение текстовых файлов из zip
4. `internal/config/config.go` — LLM_* env vars
5. `internal/api/handler/upload.go` — точка вызова
6. `main.go` — wire
**ResourceQuota:**
- `controllers/function_controller.go` → при создании namespace `sless-fn-*` создавать ResourceQuota
- Лимиты фиксированные для v1: pods=20, memory=4Gi, cpu=4
**NetworkPolicy:**
- `controllers/function_controller.go` → при создании namespace `sless-fn-*` создавать deny-all NetworkPolicy
- Разрешить: egress к DNS (kube-dns), egress к интернету (если нужно), ingress от оператора
**Файлы для создания:**
- Шаблон в контроллере, не отдельный файл
### Этап 3: Watch на Function для Trigger и FunctionJob — 2-3 часа
Замена RequeueAfter polling на правильный event-driven watch.
**Почему не в этапе 0:** RequeueAfter 15s уже закроет проблему для единиц пользователей. Правильный watch нужен когда функций станет сотни и polling начнёт создавать лишнюю нагрузку.
**Реализация:**
```go
// trigger_controller.go
func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&slessv1alpha1.Trigger{}).
Watches(
&slessv1alpha1.Function{},
handler.EnqueueRequestsFromMapFunc(r.findTriggersForFunction),
).
Complete(r)
}
func (r *TriggerReconciler) findTriggersForFunction(ctx context.Context, obj client.Object) []reconcile.Request {
fn := obj.(*slessv1alpha1.Function)
var triggers slessv1alpha1.TriggerList
_ = r.List(ctx, &triggers, client.InNamespace(fn.Namespace))
var requests []reconcile.Request
for _, tr := range triggers.Items {
if tr.Spec.FunctionRef == fn.Name {
requests = append(requests, reconcile.Request{
NamespacedName: client.ObjectKeyFromObject(&tr),
})
}
}
return requests
}
```
Аналогично для FunctionJobReconciler.
### Этап 4: Минимальные тесты — 3-4 часа
**Не гнаться за coverage.** Покрыть только то что реально ломалось.
Тесты через envtest (уже есть bootstrap в `suite_test.go`):
1. **Trigger ожидает Function Ready:** Create Function (Pending) → Create Trigger → проверить что Trigger в "waiting" → перевести Function в Ready → проверить что Trigger стал Active
2. **Cleanup при удалении:** Create Function → Create Deployment в sless-fn- → Delete Function → проверить что Deployment/Service/Ingress удалены
3. **FunctionJob не запускается при RunID=0:** Create FunctionJob(RunID=0) → проверить phase=Skipped
**Не нужны тесты на:**
- Авторизацию (один статический токен, тестировать нечего)
- S3 upload (внешняя зависимость, лучше E2E)
- Kaniko build (внешняя зависимость)
### Этап 5 (v2): Auth по токену облака — неопределённые сроки
Это зависит от инфраструктуры nubes.ru:
- Нужен endpoint для валидации токена → namespace identity
- API router вытаскивает namespace из identity, а не из URL
- Обратная совместимость: текущие Terraform-конфиги должны работать
**Не делать пока не будет:**
1. Auth-сервиса/endpoint nubes.ru для валидации токенов
2. Хотя бы 3-5 реальных пользователей, которым нужна изоляция
### Что делать когда-нибудь потом (v2+)
Эти вещи **не нужны** для текущего масштаба. Помечаю чтобы не забыть, но не тратить время:
| Тема | Когда реально нужно | Почему не сейчас |
|------|---------------------|------------------|
| Scale-to-zero (KEDA) | Когда функций >50 и оплата за ресурсы болит | Меняет архитектуру routing, сложно |
| gVisor/Kata | Когда открыт self-service signup | Overkill для invite-only |
| LLM code validation | **Перенесено в активный план (Этап 2.5)** | Dogfooding облачного LLM |
| Image scanning (Trivy) | Когда security-аудит требуется | Можно сканировать base images отдельно |
| Go runtime | Когда есть запрос от пользователей | Python + Node покрывают 90% use cases |
| Replicas в FunctionSpec | Когда пользователи жалуются на потребление | Пока 1 replica = 1 функция, просто |
| Разделение API/Operator | Когда пользователей >100 или нужен HA API | Один бинарник проще |
| RabbitMQ event triggers | Когда есть реальный event-driven use case | HTTP + Cron покрывают MVP |
| S3/Registry cleanup | Когда 100+ сборок и диск заканчивается | Ручная чистка пока достаточна |
| Versions API (rollback) | Когда пользователи просят откат | Текущая модель: re-upload = новая версия |
---
## Часть 6: Структурные наблюдения
### 6.1 Operator namespace hardcode
`main.go` хардкодит `"sless"` как OperatorNamespace:
```go
OperatorNamespace: "sless",
```
Это нормально. Namespace оператора не должен быть динамическим — это deployment-level решение. Если переносим в другой namespace, меняем одну строку. Не проблема.
### 6.2 Ошибки проглатываются через `_ = r.Status().Update(...)`
Встречается в нескольких местах:
```go
_ = r.Status().Update(ctx, tr)
```
Это осознанный trade-off: если status update упал, reconcile продолжится, и следующий Requeue перезапишет. Для status-only обновлений это OK. Главное не делать так для spec-изменений.
### 6.3 handleDeletion не чистит S3 artifacts и registry images
При удалении Function чистятся: Deployment, Service, Ingress.
Не чистятся: S3 build context, Docker image в registry.
**Для текущего масштаба это не проблема.** При 100+ функциях — начнёт накапливаться мусор. Простое решение — периодический cron-скрипт для чистки orphaned объектов, не встроенная логика в контроллер.
### 6.4 migrations/001_initial.sql читается из файла на диске
```go
migrationSQL, err := os.ReadFile("migrations/001_initial.sql")
```
Это означает что бинарник зависит от наличия файла в текущей директории. Работает с `go run` и если Docker COPY включает migrations/. Не самый robust подход, но для одного файла — приемлемо.
**Когда станет проблемой:** Когда будет 5+ миграций. Тогда стоит встроить через `go:embed` или использовать migrate-библиотеку.
### 6.5 API doc/api/design.md частично устарел
- Versions API описан, но не реализован
- Invocations API описан как рабочий, но не записывает
- Runtime list: `go1.21` помечен как "планируется", но решение отложено
**Рекомендация:** Обновить design.md после этапа 0, зафиксировав что реально работает в v1.
---
## Часть 7: Итоговая оценка
### Что делает этот проект хорошим MVP
1. **Реально работает end-to-end.** terraform apply → функция доступна по HTTPS.
2. **Прошёл через реальные проблемы** и решил их все (не застрял на happy path).
3. **Документация ошибок** лучше чем в большинстве проектов.
4. **Terraform provider** полностью функционален с source_dir, WaitReady, lifecycle control.
5. **Архитектура не over-engineered** — один бинарник, простые контроллеры, minimal dependencies.
### Что нужно для промышленной эксплуатации (небольшой провайдер)
1. **Этап 0** — быстрые фиксы (RequeueAfter, validation, pin versions) — **обязательно перед любыми пользователями**
2. **ResourceQuota + NetworkPolicy** — минимальная изоляция
3. **Auth по облачному токену** — когда инфраструктура nubes.ru будет готова
### Чего НЕ нужно делать
- Не распиливать на микросервисы
- Не внедрять gVisor/Kata
- Не интегрировать LLM-валидацию кода
- Не делать scale-to-zero
- Не добавлять Go runtime (пока нет запроса)
- Не строить versioning/rollback (пока нет запроса)
- Не менять gorilla/mux на что-то "модное"
- Не добавлять Conditions-логику в контроллеры
---
## Файловая карта: что где менять
### Этап 0 (быстрые фиксы)
| Файл | Изменение |
|------|-----------|
| `controllers/trigger_controller.go` ~87 | Добавить `return ctrl.Result{RequeueAfter: 15 * time.Second}, nil` вместо `return ctrl.Result{}, nil` |
| `controllers/functionjob_controller.go` ~111 | Аналогично |
| `controllers/trigger_controller.go` ~258 | Заменить `curlimages/curl:latest` → `curlimages/curl:8.5.0` |
| `internal/api/handler/functions.go` ~UpdateFunction | Добавить валидацию required полей |
| `internal/config/config.go` | Удалить FunctionNamespacePrefix |
| `internal/api/handler/invocations.go` или `router.go` | Заглушка 501 или убрать endpoint |
### Этап 2 (security минимум)
| Файл | Изменение |
|------|-----------|
| `controllers/function_controller.go` ~ensureDeployment | После создания namespace — создать ResourceQuota и NetworkPolicy |
### Этап 3 (watches)
| Файл | Изменение |
|------|-----------|
| `controllers/trigger_controller.go` ~SetupWithManager | Добавить Watches на Function |
| `controllers/functionjob_controller.go` ~SetupWithManager | Добавить Watches на Function |
---
## Резюме одной строкой
**Проект хороший, работает, фундамент правильный. Нужны 1 час быстрых фиксов + ResourceQuota/NetworkPolicy — и можно открывать для первых пользователей.**
@@ -0,0 +1,313 @@
# Claude Sonnet 4.6: Review of GPT-5.4 Analysis
Дата: 2026-03-10
Автор: Claude Sonnet 4.6
Контекст: Анализ документа `agent-handoff-2026-03-10.md`, подготовленного GPT-5.4
---
## Executive Summary
GPT-5.4 провёл детальный code review и выявил реальные архитектурные проблемы в текущей реализации sless. Анализ качественный, приоритизация разумная, но **фокус исключительно на lifecycle/data consistency**, при этом **полностью упущен security roadmap для production**.
**Мой вердикт:**
- ✅ GPT-5.4 правильно определил проблемы event model (A1, A2)
- ✅ Invocation history gap (A3) — точное попадание
- ✅ Приоритизация задач логична для текущего MVP
- ⚠️ **Критичный пробел:** безопасность multi-tenant serverless платформы вообще не упомянута
- ⚠️ Не учтён контекст беседы с пользователем про gVisor, LLM validation, runtime isolation
---
## Где GPT-5.4 абсолютно прав
### 1. Event Model Problems (A1, A2) — критично
**Проблема:**
```go
// trigger_controller.go
func (r *TriggerReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&sllessv1alpha1.Trigger{}). // ❌ нет watch на Function
Complete(r)
}
```
Trigger может зависнуть в `waiting` если Function не Ready на момент создания. Reconcile не произойдёт автоматически когда Function станет Ready.
**Почему это важно:** Классическая ошибка в operator patterns. Это не "незавершённая фича" а реальный race condition.
**Моё мнение:** Приоритет A1/A2 — верный. Это базовая корректность reconciliation loop.
### 2. Invocation History (A3) — не реализована
GPT-5.4 правильно выявил: таблица `invocations` есть, SaveInvocation() реализован, но **нигде не вызывается**.
```go
// invoke.go — должно быть, но нет:
// store.SaveInvocation(ctx, &storage.Invocation{...})
```
**Моё мнение:** Согласен. Это не мелкая недоработка — это разрыв между API контрактом и реальностью.
### 3. Multi-tenancy через namespace в URL (A4) — проблема для production
```go
// router.go
r.HandleFunc("/v1/namespaces/{namespace}/functions", ...)
```
Пользователь передаёт `namespace` в URL, но auth не проверяет принадлежность. Один токен = все namespace.
**Моё мнение:** Верно, но это known limitation для MVP. Важно не забыть при переходе к реальному tenant model.
### 4. Config inconsistency (B1) — мнимая конфигурируемость
```go
// config.go
FunctionNamespacePrefix string // есть поле
// function_controller.go
targetNS := "sless-fn-" + fn.Namespace // 🔥 хардкод, игнорирует конфиг
```
**Моё мнение:** Полностью согласен. Либо убрать поле либо использовать везде. Сейчас это вводит в заблуждение.
---
## Где GPT-5.4 поверхностен или не учёл контекст
### 1. Security для multi-tenant serverless — критичный пробел
GPT-5.4 **вообще не упомянул:**
- Container escape protection (gVisor/Kata)
- Network isolation между namespace (NetworkPolicy)
- Code validation перед деплоем
- Resource abuse prevention
- Kernel-level isolation
**Проблема:** Для публичного managed serverless это не "nice to have" — это базовое требование безопасности.
**Контекст из беседы:**
Пользователь спрашивал: "как быть? всё должно быть безопасно". Обсуждали:
- gVisor — sandbox runtime для защиты от container escape
- NetworkPolicy — изоляция трафика между namespace
- LLM validation — проверка кода на malicious patterns
- Capsule/vCluster — для усиленной изоляции
**Мой вывод:** GPT-5.4 дал план для "внутреннего инструмента", но не для "облачного продукта".
### 2. LLM integration для security — не упомянута
**Контекст из беседы:**
- "LLM скоро запустят в облаке. и он должен себя оправдывать"
- Идея: builder вызывает LLM для проверки кода перед сборкой
- Защита от криптомайнеров, ботнетов, DDoS-агентов
GPT-5.4 вообще не включил это в roadmap, хотя пользователь явно видит это частью системы.
**Мой вывод:** LLM validation должна быть в плане после исправления lifecycle issues, но до production launch.
### 3. Go runtime — не упомянут
**Контекст из беседы:**
- "пока не надо, но надо golang будет"
- Обсуждали двухэтапный Dockerfile (builder → scratch)
- Go даёт ~5-10MB бинарь vs ~100MB Python/Node
GPT-5.4 не включил Go runtime в roadmap, хотя это логичный next step после стабилизации существующих рантаймов.
### 4. Production deployment model — не рассмотрен
GPT-5.4 правильно отметил что один бинарь (API + operator) "нормален для v1", но не дал vision когда и как разделять.
**Что пропущено:**
- API становится data plane proxy → bottleneck
- HA для operator manager (leader election есть, но не обсуждается)
- Separate control/data plane для масштабирования
**Мой вывод:** Для MVP текущая архитектура OK, но GPT-5.4 мог дать более чёткий trigger point для refactor.
---
## Где я частично не согласен
### 1. Timeout enforcement (B2) — не приоритет сейчас
GPT-5.4 включил TimeoutSec в "критичность B", но реально это less critical чем security.
**Моё мнение:**
- Runtime timeout — nice to have для MVP
- Можно задокументировать как "informational field" и вернуться позже
- Важнее NetworkPolicy + gVisor чем timeout enforcement
### 2. Test coverage (B4) — правильно отмечено, но слишком общо
GPT-5.4: "Тестов ядра почти нет".
**Моё мнение:**
- Да, это проблема
- Но конкретные приоритеты тестов должны быть:
1. Event model (Trigger/FunctionJob watch на Function)
2. Cleanup finalizers
3. Invocation history recording
- Не надо гнаться за coverage %, надо покрыть critical paths
---
## Production Security Roadmap (пропущен GPT-5.4)
Это должно быть в handoff, но отсутствует.
### Immediate (до первых external users)
1. **gVisor RuntimeClass**
```yaml
spec:
runtimeClassName: gvisor
```
- Защита от container escape
- ~10-20% performance overhead
- Установка на ноды + RuntimeClass в k8s
2. **NetworkPolicy per namespace**
```yaml
kind: NetworkPolicy
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
ingress: [] # Deny all by default
```
- Запретить трафик между `sless-fn-*` namespace
- Разрешить только к external services + internal DNS
3. **ResourceQuota per tenant**
```yaml
apiVersion: v1
kind: ResourceQuota
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
pods: "50"
```
### Near-term (параллельно с lifecycle fixes)
4. **Static code analysis в builder**
- Python: `bandit` before Docker build
- Node.js: `npm audit`
- Блокировка деплоя при critical issues
5. **Image scanning**
- Trivy/Grype после kaniko build
- Reject образа если critical CVE
### Medium-term (перед production launch)
6. **LLM validation hook**
- API call к облачному LLM перед сборкой
- Детект криптомайнеров, port scanners, DDoS agents
- Premium feature или throttled для free tier
7. **True tenant isolation**
- Identity из JWT → namespace mapping
- User не может передать namespace в URL
- RBAC per tenant
---
## Рекомендуемый порядок работ (мой взгляд vs GPT-5.4)
### GPT-5.4 предложил:
1. Event model (Trigger/FunctionJob watch)
2. Invocation history
3. Timeout/validation sync
4. Config consistency
5. Tests
### Я рекомендую:
1. ✅ Event model — согласен, приоритет #1
2. ✅ Invocation history — согласен, приоритет #2
3. ⚠️ **Добавить: gVisor + NetworkPolicy** (до external users)
4. ✅ Config consistency
5. ✅ Tests (но фокус на critical paths)
6. ⚠️ **Добавить: Static analysis в builder**
7. Timeout enforcement (lower priority)
8. ⚠️ **Добавить: LLM validation (перед production)**
---
## Практические дополнения для следующего агента
### Что делать с GPT-5.4 handoff
**Использовать как base**, но:
1. После этапа 2 (invocation history) → добавить security hardening
2. Перед production launch → полный security audit + LLM integration
3. Go runtime → после стабилизации Python/Node (не urgent)
### Конкретные файлы для security work (пропущены GPT-5.4)
**gVisor:**
- `controllers/function_controller.go` — добавить `RuntimeClassName: "gvisor"` в PodSpec
- Установка на ноды (вне кода)
**NetworkPolicy:**
- `deployments/k8s/network-policy.yaml` — создать template
- `controllers/function_controller.go` — создавать NetworkPolicy per namespace
**Code validation:**
- `internal/builder/builder.go` — добавить pre-build validation hook
- `internal/validator/` (новый) — интеграция с bandit/npm audit
**LLM validation:**
- `internal/validator/llm.go` — API client к облачному LLM
- `internal/api/handler/upload.go` — вызов перед S3 upload
---
## Файлы которые следующий агент должен создать/изучить
**GPT-5.4 не упомянул, но критичны:**
1. `doc/security/multi-tenancy-model.md` — дизайн tenant isolation
2. `doc/security/runtime-isolation.md` — gVisor/Kata decision + implementation
3. `deployments/k8s/network-policy-template.yaml` — NetworkPolicy per tenant
4. `deployments/k8s/resource-quota-template.yaml` — ResourceQuota per tenant
5. `internal/validator/` — новый пакет для code validation
6. `doc/decisions/gvisor-vs-kata.md` — почему gVisor for MVP
---
## Окончательный вердикт
**GPT-5.4 провёл отличный технический аудит кода**, но смотрел через призму "внутреннего инструмента", а не "managed cloud service".
**Сильные стороны анализа:**
- ✅ Детальный разбор lifecycle problems
- ✅ Конкретные файлы и строки кода
- ✅ Приоритизация по критичности
- ✅ Практичный план для MVP stabilization
**Пробелы:**
- ❌ Security roadmap отсутствует
- ❌ Не учтён контекст беседы (gVisor, LLM, Go runtime)
- ❌ Production deployment model не рассмотрен
- ❌ Tenant isolation strategy не обсуждается
**Рекомендация:**
Использовать GPT-5.4 handoff как **техническую базу**, дополнить моими замечаниями по security, затем скомбинировать в единый production roadmap.
**Для следующего агента:**
1. Читать GPT-5.4 документ для lifecycle issues
2. Читать этот документ для security roadmap
3. Начинать с lifecycle (приоритет GPT-5.4)
4. После стабилизации — security hardening (мой приоритет)
5. Перед production launch — полный аудит (желательно Claude Opus 4.6)
---
## Действия для этой сессии
Следующий шаг: обновить `doc/progress.md`, зафиксировать оба анализа (GPT-5.4 + мой), чтобы следующий агент видел полную картину.
+432
View File
@@ -14,4 +14,436 @@
**Решение:** ...
```
## 2026-03-07 — kaniko: unsupported protocol scheme ""
**Проблема:** kaniko Job падает с `unsupported protocol scheme ""` при доступе к S3.
**Причина:** В env `S3_ENDPOINT` передавался хост без схемы (`s3.msk-1.ngcloud.ru`), kaniko ожидает полный URL.
**Решение:** В `builder.go` добавить `"https://" + b.s3Endpoint` при формировании env для kaniko. Также добавить `S3_FORCE_PATH_STYLE=true` — Ceph требует path-style URLs.
---
## 2026-03-07 — Бесконечный цикл сборки (94 job'а)
**Проблема:** После upload оператор создавал сотни kaniko Job'ов.
**Причина:** Upload handler вызывал `Status().Update(phase=Pending)` ПОСЛЕ того как контроллер уже выставил `Building`. Контроллер видел `Pending` → создавал новый Job, upload снова сбрасывал → цикл.
**Решение:**
1. `startBuild()` СНАЧАЛА ставит аннотацию `last-built-s3key = spec.S3Key` (idempotency guard), затем обновляет статус.
2. Reconcile запускает сборку только если `spec.S3Key != annotations["last-built-s3key"]`.
3. Upload handler убран `Status().Update()` — контроллер сам управляет фазой.
4. `IsAlreadyExists` при создании Job не считается ошибкой (parallel reconcile).
---
## 2026-03-07 — upload.go: "object has been modified; please apply your changes"
**Проблема:** `terraform apply` падал при попытке upload кода функции:
```
status 500: {"error":"update function: Operation cannot be fulfilled on functions.sless.kube5s.ru "pg-query": the object has been modified; please apply your changes to the latest version and try again"}
```
**Причина:** В `upload.go` использовался `h.K8s.Update(ctx, fn)`. Между `Get()` и `Update()` контроллер успевал изменить объект (выставить статус/аннотации) — `resourceVersion` в памяти устаревал, API-сервер отклонял обновление.
**Решение:** Заменить `Update` на `Patch` (strategic merge patch):
```go
patch := client.MergeFrom(fn.DeepCopy())
fn.Spec.S3Key = s3Key
fn.Spec.S3Bucket = h.S3.Bucket()
h.K8s.Patch(ctx, fn, patch)
```
`MergePatch` передаёт только изменённые поля и не требует точного `resourceVersion`.
---
## 2026-03-07 — Terraform Provider: "provider produced inconsistent result" для schedule
**Проблема:** При создании `sless_trigger` с `type=http` (без поля `schedule`) terraform падал:
```
.schedule: was null, but now cty.StringVal("")
```
**Причина:** В `trToModel()` провайдера `Schedule` всегда возвращался как `types.StringValue(tr.Schedule)`. Если schedule не задан, API возвращает пустую строку `""`, провайдер сохранял `""` в state. Terraform видел расхождение: в плане было `null` (поле не задано в HCL), в state после apply стало `""`.
**Решение:** В `trToModel()` возвращать `types.StringNull()` когда schedule пустая строка:
```go
schedule := types.StringNull()
if tr.Schedule != "" {
schedule = types.StringValue(tr.Schedule)
}
```
Правило: для `Optional` computed-полей пустая строка из API → `null` в state.
---
## 2026-03-09 — source_dir: hashicorp/archive недоступен без VPN
**Проблема:** С VPN недоступен `kube5s.ru` (registry провайдера), без VPN — `registry.terraform.io` (hashicorp/archive). Примеры не работали ни с VPN ни без.
**Причина:** Зависимость от внешнего провайдера `hashicorp/archive` для создания zip.
**Решение:** Добавить атрибут `source_dir` в `sless_function`. Провайдер сам упаковывает директорию в zip (`archive/zip` in-memory), считает SHA256 (`crypto/sha256`). Файлы сортируются для детерминированного хэша. `hashicorp/archive` убран из всех примеров.
**Provider:** v0.1.10.
---
## 2026-03-09 — Destroy route cleanup bug: endpoint 502 после terraform destroy
**Проблема:** После `terraform destroy` публичный HTTP-эндпоинт продолжал отвечать — сначала 200, затем 502 `function unreachable`. Тестовый скрипт ждал 120 секунд и падал.
**Причина:** Три независимые проблемы в связке:
1. `trigger_controller.go` `handleTriggerDeletion` намеренно не удалял Service и Ingress (комментарий «оставляем — могут быть нужны другим триггерам»). Неверно: имена Service/Ingress совпадают с именем функции, а не триггера.
2. `function_controller.go` `handleDeletion` удалял только Deployment, не трогал Service и Ingress.
3. `invoke.go` при DNS NXDOMAIN (`no such host`) возвращал 502, а тестовый скрипт ждёт 404. Итог: Ingress висел вечно → 502 висел 120с.
**Решение:**
1. `handleTriggerDeletion`: для HTTP-триггеров явно удалять `Service` и `Ingress` с именем `fn.Spec.FunctionRef` в `sless-fn-{ns}`.
2. `handleDeletion`: добавить удаление `Service` и `Ingress` с именем `fn.Name` в `sless-fn-{ns}` (импорт `netv1`).
3. `trigger_resource.go` Delete: polling `GetTrigger` каждые 3с до 90с — провайдер не возвращает успех раньше завершения cleanup.
4. `invoke.go`: `no such host` → HTTP 404 вместо 502 — Service удалён, эндпоинт мёртв.
**Operator:** v0.1.17 (контроллеры), v0.1.18 (invoke.go). **Provider:** v0.1.11.
---
## 2026-03-07 — Terraform Provider: "Resource Import Not Implemented"
**Проблема:** После failed `terraform apply` (Function создалась, Trigger упал) state не содержал созданных ресурсов. Повторный apply падал с `status 409: function already exists`. `terraform import` тоже не работал:
```
This resource does not support import. Please contact the provider developer
```
**Причина:** В провайдере не реализован `ImportState` для ресурса `sless_function`.
**Решение (краткосрочное):** Удалить ресурсы из кластера вручную (`kubectl delete`) и повторить apply с чистым state.
**Решение (долгосрочное / TODO):** Реализовать `ImportState` для `sless_function` и `sless_trigger`:
```go
func (r *FunctionResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
// ID формат: "namespace/name"
resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp)
}
```
---
## 2026-03-07 — handler.py: column "started_at" does not exist
**Проблема:** При вызове функции pg-query в логах пода:
```
psycopg2.errors.UndefinedColumn: column "started_at" does not exist
```
**Причина:** В `examples/pg-query/handler.py` использовалось имя колонки `started_at`, но в схеме `migrations/001_initial.sql` таблицы `invocations` колонка называется `created_at`.
**Решение:** Исправить `select` и `dict()` в handler.py: `started_at` → `created_at`.
**Урок:** При написании примеров всегда сверяться со схемой в `migrations/`.
---
## 2026-03-07 — Deployment не перезапустил pod после re-build образа
**Проблема:** После `terraform apply` (re-upload + kaniko пересборка) pod продолжал использовать старый код — `curl` возвращал ошибку `started_at`.
**Причина:** Образ тегируется как `:latest`. Kubernetes не перезапускает pod автоматически если тег не изменился — даже если образ на DockerHub обновился. `imagePullPolicy` по умолчанию `IfNotPresent` для non-digest образов, только `Always` гарантирует pull при каждом запуске.
**Решение (краткосрочное):** Явный `kubectl rollout restart deployment/pg-query -n sless-fn-default`.
**Решение (долгосрочное / TODO):** В `function_controller.go` после успешной сборки добавить `rollout restart` аннотацию:
```go
// Форсируем rollout через аннотацию kubectl.kubernetes.io/restartedAt
deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339)
```
Либо использовать версионированные теги образов вместо `:latest` (`:v{timestamp}`).
---
## 2026-03-08 — output_md5 archive провайдера не обновляется при изменении кода
**Проблема:** `terraform plan` показывал "No changes" даже после изменения JS-файла функции. Функция отдавала старый код.
**Причина:** `code_hash = data.archive_file.handler_http.output_md5` — атрибут `output_md5` в `hashicorp/archive v2.7.x` возвращает MD5 от **предыдущей** версии zip (баг порядка вычисления). `output_sha` и `output_sha256` от того же файла обновлялись корректно.
**Решение:** Заменить `output_md5` на `filesha256("${path.module}/code/handler.js")` — хэшируется исходный файл напрямую, минуя archive провайдер.
**Урок:** Не использовать `output_md5` из `archive_file`. Использовать `filesha256(source_file)`.
---
## 2026-03-08 — FunctionJob зависал в Running навсегда
**Проблема:** `terraform apply` висел на `sless_job.hello_run: Still creating...` бесконечно. k8s Job давно завершился (Complete), но FunctionJob оставался в `Running`.
**Причина:** `SetupWithManager` использовал `Owns(&batchv1.Job{})` для watch событий Job. Но Job создавался в namespace `sless-fn-default`, а FunctionJob в `default` — cross-namespace OwnerReference не работают в k8s. Reconcile после завершения Job никогда не вызывался.
**Решение:** Убрать `Owns(&batchv1.Job{})`. В `syncJobStatus` добавить `return ctrl.Result{RequeueAfter: 5 * time.Second}` когда Job ещё выполняется — polling каждые 5 сек.
---
## 2026-03-08 — ImagePullBackOff: sless-registry-auth не копировался в sless-fn-*
**Проблема:** Pod функции падал с `ImagePullBackOff` — не мог скачать образ из registry.
**Причина:** `buildDeployment` в `function_controller.go` не устанавливал `imagePullSecrets` в PodSpec. Секрет `sless-registry-auth` существовал только в namespace `sless`, но не копировался в `sless-fn-default`.
**Решение:** Добавить `ensureRegistrySecret()` — копирует секрет из `sless` в `sless-fn-*` при каждом reconcile. В `buildDeployment` добавить `ImagePullSecrets`.
---
## 2026-03-08 — CrashLoopBackOff: Cannot find module handler.js
**Проблема:** Pod функции падал: `Cannot find module '/app/function/handler.js'`. Entrypoint был `handler-http.handle` (файл `handler-http.js`), а server.js искал хардкоженный `handler.js`.
**Причина:** `server.js` и `server.py` runtime-образов хардкодили имя модуля `handler`. Поле `SLESS_ENTRYPOINT` env var не использовалось.
**Решение:** `server.js` и `server.py` переписаны: парсят `SLESS_ENTRYPOINT` (формат `"module.funcName"`), динамически загружают нужный модуль. Runtime пересобран как `v0.1.1`.
---
## 2026-03-08 — Kaniko cache: пересобирал образ со старым server.js
**Проблема:** После исправления `server.js` kaniko продолжал собирать образы с **старым** server.js из кэша.
**Причина:** Base image тегирован как `:latest`. Kaniko кэшировал слои — `FROM naeel/sless-runtime-nodejs20:latest` не перечитывал обновлённый образ.
**Решение:** Новый тег базового образа `v0.1.1`. В `upload.go` изменить `runtimeBaseImage("nodejs20")` → `naeel/sless-runtime-nodejs20:v0.1.1`.
---
## 2026-03-08 — RBAC: secrets is forbidden: cannot list
**Проблема:** Оператор падал: `secrets is forbidden: User "system:serviceaccount:sless:sless-operator" cannot list resource "secrets"`.
**Причина:** В `rbac.yaml` для secrets были только `get;create`, нужны также `list;watch` для controller-runtime.
**Решение:** Добавить `list;watch` в ClusterRole для `resources: ["secrets"]`.
---
## 2026-03-08 — Harbor registry нестабилен (504 Gateway Timeout)
**Проблема:** Kaniko падал: `GET https://pearlharbor.registryk8s.services.ngcloud.ru/v2/: unexpected status code 504 Gateway Timeout`. Периодически `/v2/` зависал на 10+ секунд или не отвечал вовсе.
**Причина:** Harbor — внешний сервис облачного провайдера, нестабилен по независящим от нас причинам. Поведение не зависит от VPN.
**Решение:** Переключить `REGISTRY_HOST` с Harbor на DockerHub (`naeel`). Образы функций теперь пушатся как `naeel/sless-default-{namespace}-{name}:latest`.
---
## 2026-03-08 — Provider: inconsistent result — image_ref изменился после apply
**Проблема:** После `terraform apply` ошибка:
```
Provider produced inconsistent result after apply
.image_ref: was cty.StringVal("pearlharbor..."), but now cty.StringVal("naeel/...")
```
**Причина:** `image_ref` в схеме провайдера имел `UseStateForUnknown()`. При plan terraform брал значение из state (старый Harbor путь). После apply API возвращал новый DockerHub путь. Расхождение план vs результат.
**Решение:** Убрать `UseStateForUnknown()` с `image_ref`. Теперь `image_ref` = `(known after apply)` при каждом apply — честно отражает что значение неизвестно до сборки. Провайдер `v0.1.5`.
---
## 2026-03-08 — Provider: inconsistent result — phase Failed→Ready
**Проблема:**
```
Provider produced inconsistent result after apply
.phase: was cty.StringVal("Failed"), but now cty.StringVal("Ready")
```
**Причина:** При ошибке `WaitReady` (build failed) → `AddError` → `return`. `resp.State.Set` не вызывался → state не обновлялся → в state оставалось старое значение. Затем оператор замечал новый код, пересобирал → функция становилась Ready. Следующий `Read` возвращал `phase=Ready`, terraform видел расхождение.
**Решение:** Убрать `UseStateForUnknown()` с `image_ref` (связанный фикс). Для `phase` — `UseStateForUnknown()` оставлен, т.к. при нормальном flow `Ready→Ready` предсказуем.
---
## 2026-03-08 — Стейл-сервис hello-node блокировал proxy запросы
**Проблема:** `GET /fn/default/hello-node` возвращал `operation not permitted` — `dial tcp 10.100.77.255:8080: connect: operation not permitted`.
**Причина:** Сервис `hello-node` в `sless-fn-default` существовал 26h с ClusterIP `10.100.77.255` но без Deployment/Pod. iptables дропал пакеты на несуществующий endpoint.
**Решение:** `kubectl delete svc hello-node -n sless-fn-default`.
---
## 2026-03-07 — Docker credStore: exec "docker-credential-desktop.exe"
**Проблема:** `docker push` падал с `exec: "docker-credential-desktop.exe": executable file not found`.
**Причина:** В `~/.docker/config.json` осталась запись `"credsStore": "desktop.exe"` от Docker Desktop.
**Решение:** Удалить поле `credsStore` из `~/.docker/config.json`, оставить только `auths` с base64 credentials.
---
## 2026-03-07 — controller-gen v0.11.1 паникует с Go 1.23
**Проблема:** `operator-sdk create api` падает с panic в `controller-gen` при `make generate`.
**Причина:** controller-tools v0.11.1 несовместим с Go 1.23 (nil pointer dereference в go/types).
**Решение:** В `Makefile` заменить `CONTROLLER_TOOLS_VERSION ?= v0.11.1` на `v0.14.0`.
---
## 2026-03-07 — Dockerfile: operator не находит migrations/ при старте
**Проблема:** Оператор в кластере сразу падает в CrashLoopBackOff:
```
level=ERROR msg="read migration file" err="open migrations/001_initial.sql: no such file or directory"
```
**Причина:** Оригинальный `Dockerfile` копировал только `api/`, `controllers/`, `main.go`. Директории `internal/` и `migrations/` в образ не попадали.
**Решение:** Добавить в оба stage Dockerfile:
```dockerfile
COPY internal/ internal/ # в builder stage
COPY migrations/ migrations/ # и в builder, и в финальный alpine stage
```
`migrations/` нужен в финальном образе — оператор читает SQL-файлы в runtime при каждом старте.
---
## 2026-03-07 — Dockerfile: golang:1.23 несовместим с go.mod (требует 1.25)
**Проблема:** `docker build` падает на `go mod download`:
```
go: go.mod requires go >= 1.25 (running go 1.23.12; GOTOOLCHAIN=local)
```
**Причина:** В `go.mod` указан `go 1.25`, Dockerfile использовал `golang:1.23-alpine`.
**Решение:** `FROM golang:1.23-alpine` → `FROM golang:1.25-alpine`.
---
## 2026-03-07 — go.mod: прямые зависимости помечены как indirect
**Проблема:** gopls показывал ошибки в `go.mod`:
```
github.com/gorilla/mux should be direct
github.com/lib/pq should be direct
github.com/minio/minio-go/v7 should be direct
k8s.io/api should be direct
```
**Причина:** Пакеты получили `// indirect` несмотря на прямые импорты — побочный эффект scaffold-генерации operator-sdk.
**Решение:** `go mod tidy` автоматически расставил правильные аннотации.
---
## 2026-03-08 — После kaniko rebuild функция возвращает старый код
**Проблема:** `terraform apply` с изменённым кодом успешно завершал kaniko build, но функция продолжала возвращать старую версию кода.
**Причина:** `imagePullPolicy: IfNotPresent` (default в k8s) + `:latest` тег — после успешной сборки оператор обновлял `.spec.template.spec.containers[0].image`, но image tag не менялся (всегда `:latest`). Kubernetes видел что образ уже есть на ноде и не пул-ил новый. Pod оставался запущенным со старым образом.
**Решение:** В `controllers/function_controller.go`, функция `ensureDeployment`, после обновления image добавлена аннотация:
```go
existing.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = fn.Status.LastBuiltAt.Time.Format(time.RFC3339)
```
Аннотация меняется при каждой новой сборке (значение = `lastBuiltAt`) → Kubernetes делает rolling restart → kubelet пул-ит свежий образ.
Оператор: **v0.1.11**.
---
## 2026-03-09 — Негативные тесты API: найденные баги валидации
### Методология тестирования
Тесты запускались через прямые POST-запросы к API (`https://sless-api.kube5s.ru`)
со специально некорректными параметрами. Скрипт: `/tmp/sless_negative_tests.sh`.
### Результаты
| # | Тест | Ожидание | Факт | Статус |
|---|---|---|---|---|
| T1 | `runtime="python999"` | 400 ошибка | `Unsupported value: "python999"` | ✅ |
| T2 | `memory_mb=-10` | 400 ошибка | **201 Created** — принято без ошибки | ❌ БАГ |
| T3 | `name=""` | 400 ошибка | `name and runtime are required` | ✅ |
| T4 | `entrypoint=""` (не передан) | 400 ошибка | **201 Created** с `entrypoint:""` | ❌ БАГ |
| T5 | trigger → несуществующая fn | 404/400 | `name, type and function are required` | ❌ Неверный текст ошибки |
| T6 | job → несуществующая fn | 404/400 | **Создался** (fn не проверяется при создании job) | ⚠️ |
| T7 | trigger `type="rabbitmq"` | 400 ошибка | `name, type and function are required` | ❌ Неверный текст ошибки |
| T8 | `run_id=0` | job создаётся, не запускается | 201, `run_id:0` | ✅ |
| T9 | несуществующий namespace | 404 | `namespaces "nonexistent-ns" not found` | ✅ |
| T10 | `memory_mb=999999` | 400 или ограничение | **201, вернул memory_mb=128** (молча обрезан) | ❌ БАГ |
### Баги для исправления
**БАГ-1 (API handler/function):** `memory_mb <= 0` не валидируется. Нужно: `if req.MemoryMB <= 0 { return 400 }`.
**БАГ-2 (API handler/function):** `entrypoint == ""` не валидируется. Нужно: проверять непустой entrypoint.
**БАГ-3 (API handler/trigger):** При передаче `type="rabbitmq"` или несуществующей `functionRef` ошибка говорит `"name, type and function are required"` — неверный текст. Скорее всего handler проверяет поле `function_ref` (snake_case) а принимает `functionRef` (camelCase) — маппинг не работает.
**БАГ-4 (API handler/function):** `memory_mb=999999` принято, но возвращён `memory_mb=128` — молчаливое изменение без ошибки.
### Дополнительно: валидация в Terraform провайдере (plan-time)
Провайдер не имел schema-level валидаторов — все ошибки проявлялись только на `apply`.
Добавлены validators в `function_resource.go`, `trigger_resource.go`, `job_resource.go`:
- `runtime` — только допустимые значения
- `memory_mb` — диапазон 64-4096
- `timeout_sec` — диапазон 1-900
- `trigger.type` — только `http`/`cron`
- `run_id` — >= 0
---
## Исправления (2026-03-08): v0.1.13 оператор + v0.1.7 провайдер
### API исправления (internal/api/handler/)
**БАГ-1 FIXED (functions.go):** Добавлена валидация `memory_mb`:
```
if req.MemoryMB <= 0 || req.MemoryMB > 4096 → 400 "memory_mb must be between 1 and 4096"
```
**БАГ-2 FIXED (functions.go):** Добавлена валидация `entrypoint`:
```
if req.Entrypoint == "" → 400 "entrypoint is required"
```
**БАГ-3 FIXED (triggers.go):** Добавлена валидация `type`:
```
if req.Type != "http" && req.Type != "cron" → 400 "type must be \"http\" or \"cron\""
```
**БАГ-4 FIXED (functions.go):** Покрыт случаем T10 — `memory_mb=999999` теперь возвращает 400.
### Провайдер план-валидаторы (terraform/provider/)
Добавлен пакет `terraform-plugin-framework-validators v0.19.0`.
`function_resource.go`:
- `runtime` — `stringvalidator.OneOf("nodejs20", "python3.11", "go1.21")`
- `memory_mb` — `int64validator.Between(1, 4096)`
- `timeout_sec` — `int64validator.Between(1, 900)`
`trigger_resource.go`:
- `type` — `stringvalidator.OneOf("http", "cron")`
`job_resource.go`:
- `run_id` — `int64validator.AtLeast(0)`
### Подтверждение повторными тестами (API)
| Тест | Ожидание | Результат |
|------|----------|----------|
| T2: `memory_mb=-10` | 400 | ✅ `{"error":"memory_mb must be between 1 and 4096"}` |
| T4: `entrypoint=""` | 400 | ✅ `{"error":"entrypoint is required"}` |
| T7: `type="rabbitmq"` | 400 + правильное сообщение | ✅ `{"error":"type must be \"http\" or \"cron\""}` |
| T10: `memory_mb=999999` | 400 | ✅ `{"error":"memory_mb must be between 1 and 4096"}` |
### Подтверждение план-валидаторов (Terraform)
Тест: `runtime = "python2.7"` в http.tf →
```
Error: Invalid Attribute Value Match
Attribute runtime value must be one of: ["nodejs20" "python3.11" "go1.21"], got: "python2.7"
```
### Версии
- Оператор: `naeel/sless-operator:v0.1.13`
- Провайдер: `terra.k8c.ru/naeel/sless v0.1.7`
+27 -5
View File
@@ -14,20 +14,42 @@
| Namespace | Что там |
|-----------|---------|
| `sless` | API сервер, PostgreSQL, Redis |
| `sless-fn-{id}` | Функции пользователей (Jobs/Deployments) |
| `sless` | PostgreSQL, Secret sless-registry-auth, RBAC |
| `sless-fn-{namespace}` | Deployments функций и их Services/Ingress |
| `terra` | Terraform registry-server, terraform-operator |
## Terraform Registry
- **URL:** `https://terra.k8c.ru`
- Image: `naeel/terraform-registry-server:gpg-key-20260208`
- Хранилище: S3 `terraform-registry`, путь: `{hostname}/{namespace}/{provider}/{version}/`
- Провайдер sless: `terra.k8c.ru/naeel/sless` v0.1.0 опубликован
- GPG ключ подписи: `CB3A0DF161ECC416` (файл: `terraform/secrets/private_key.asc`)
## S3
- **URL:** `http://ceph.tst.nubes.ru/`
- **URL:** `https://s3.msk-1.ngcloud.ru` (HTTPS обязателен — Ceph требует схему)
- **Тип:** Ceph S3 compatible
- Доступ: внешний, через access/secret key
- Бакет для кода функций: `sless-functions`
- Бакет для Terraform registry: `terraform-registry`
- **Важно:** kaniko требует `S3_FORCE_PATH_STYLE=true` и полный URL с `https://`
- Путь для контекстов сборки: `contexts/{namespace}/{name}/{timestamp}.tar.gz`
## Container Registry
- Внутренний registry кластера в namespace `registry`
- Service: `registry.registry.svc.cluster.local:18080`
- Namespace `registry` — это **Apache NiFi Registry** (НЕ Docker registry), порты 18080/18443
- Docker registry: **DockerHub**, user `naeel`
- Образы функций: `naeel/sless-{namespace}-{name}:latest`
- Runtime образы: `naeel/sless-runtime-{runtime}:latest`
- Для kaniko: Secret `sless-registry-auth` (docker-registry type) в namespace `sless`
## DockerHub образы
| Образ | Назначение |
|-------|------------|
| `naeel/sless-runtime-python3.11:latest` | Base runtime для python3.11 функций |
| `naeel/sless-default-hello:latest` | Пример собранной функции (kaniko) |
## Мониторинг
+114 -14
View File
@@ -1,31 +1,131 @@
# Прогресс разработки
Последнее обновление: 2026-03-10 (добавлен прагматичный обзор Claude Opus 4.6)
## Статусы: ✅ готово | 🔄 в процессе | ⏳ не начато
---
## 2026-03-10 — Архитектурный разбор и handoff для следующего агента
| # | Задача | Статус | Заметки |
|---|--------|--------|---------|
| 1 | GPT-5.4: Подробный разбор кода и lifecycle issues | ✅ | `doc/architecture/agent-handoff-2026-03-10.md` — детальный code review, event model problems, invocation history gap, приоритеты |
| 2 | Claude Sonnet 4.6: Review + security roadmap | ✅ | `doc/architecture/sonnet-review-of-gpt-analysis.md` — где GPT-5.4 прав, что пропустил (gVisor, LLM validation, NetworkPolicy), production security roadmap |
| 3 | Claude Opus 4.6: Прагматичный обзор для небольшого провайдера | ✅ | `doc/architecture/opus-pragmatic-review-2026-03-10.md` — анализ с учётом масштаба, конкретный план по этапам, разбор где коллеги перемудрили |
| 4 | Claude Opus 4.6: Дизайн LLM-валидации кода | ✅ | `doc/decisions/log.md` → "2026-03-10 — LLM-валидация кода при upload" — архитектура, интерфейс, prompt, план файлов |
---
## v1 — Базовый сервис
### Код
| # | Компонент | Статус | Заметки |
|---|-----------|--------|---------|
| 1 | Структура проекта, go mod init | ⏳ | |
| 2 | internal/config | ⏳ | |
| 3 | internal/model | ⏳ | |
| 4 | Миграции PostgreSQL | ⏳ | |
| 5 | internal/storage/postgres | ⏳ | |
| 6 | internal/api — роутер + хендлеры | ⏳ | |
| 7 | internal/storage/s3 | ⏳ | |
| 8 | internal/builder | ⏳ | |
| 9 | internal/runner (k8s) | ⏳ | |
| 10 | docker-compose.yml (local dev) | ⏳ | |
| 11 | deployments/k8s манифесты | ⏳ | |
| 1 | Структура проекта, operator-sdk init | ✅ | module: gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless |
| 2 | CRD types: Function, Trigger | ✅ | api/v1alpha1/, zz_generated.deepcopy.go |
| 3 | internal/config | ✅ | config.go, Load() из env vars, добавлены IngressHost + APIToken |
| 4 | Миграции PostgreSQL | ✅ | migrations/001_initial.sql — таблица invocations |
| 5 | internal/storage/postgres | ✅ | store.go: SaveInvocation, ListInvocations, RunMigrations |
| 6 | internal/storage/s3 | ✅ | client.go: Upload, Download, Delete, EnsureBucket (Ceph S3) |
| 7 | internal/builder | ✅ | builder.go: kaniko Jobs для сборки образов функций |
| 8 | controllers/function_controller.go | ✅ | Pending→Building(kaniko)→Ready/Failed, Deployment, finalizer; handleDeletion удаляет Service+Ingress |
| 9 | controllers/trigger_controller.go | ✅ | HTTP→Service+Ingress (fallback) или прокси через ExternalURL, Cron→CronJob, finalizer; handleTriggerDeletion удаляет Service+Ingress |
| 10 | internal/api/router.go | ✅ | gorilla/mux, /v1/namespaces/{ns}/... (auth), /fn/{ns}/{name} (публичный прокси) |
| 11 | internal/api/handler/ | ✅ | functions.go, triggers.go, invocations.go, invoke.go (proxy — no such host → 404), handler.go (slog) |
| 12 | internal/api/middleware/ | ✅ | auth.go (Bearer token), logging.go (slog) |
| 13 | main.go | ✅ | operator manager + HTTP сервер параллельно, wire всех зависимостей |
### Кластер (namespace: sless)
| # | Ресурс | Статус | Заметки |
|---|--------|--------|---------|
| 1 | namespace sless | ✅ | создан |
| 2 | CRD functions.sless.kube5s.ru | ✅ | установлен из config/crd/bases/ |
| 3 | CRD triggers.sless.kube5s.ru | ✅ | установлен из config/crd/bases/ |
| 4 | ServiceAccount sless-operator | ✅ | deployments/k8s/rbac.yaml |
| 5 | ClusterRole + ClusterRoleBinding | ✅ | deployments/k8s/rbac.yaml |
| 6 | PostgreSQL Deployment + Service | ✅ | deployments/k8s/postgres.yaml, Running |
| 7 | Secret postgres-secret | ✅ | в namespace sless |
### Runtime image + Upload endpoint (2026-03-07)
| # | Компонент | Статус | Заметки |
|---|-----------|--------|---------|
| 1 | runtimes/python3.11/server.py | ✅ | HTTP wrapper :8080, loads /app/function/handler.py |
| 2 | runtimes/python3.11/Dockerfile | ✅ | FROM python:3.11-slim, naeel/sless-runtime-python3.11:v0.1.0 |
| 3 | Secret sless-registry-auth | ✅ | DockerHub creds для kaniko в namespace sless |
| 4 | internal/api/handler/upload.go | ✅ | POST /v1/namespaces/{ns}/functions/{name}/upload |
| 5 | Fix: kaniko S3 endpoint | ✅ | https:// + S3_FORCE_PATH_STYLE=true (Ceph) |
| 6 | Fix: бесконечный цикл сборки | ✅ | idempotency guard через аннотацию last-built-s3key |
### E2E тест (2026-03-07)
| # | Шаг | Результат |
|---|-----|-----------|
| 1 | Запуск оператора локально | ✅ `source hack/local.env && go run main.go` |
| 2 | Создание функции hello + upload handler.zip | ✅ kaniko собрал naeel/sless-default-hello:latest |
| 3 | Function phase=Ready, Deployment Running | ✅ namespace sless-fn-default |
| 4 | `GET /health` | ✅ `{"status": "ok"}` |
| 5 | `POST / {"name":"Naeel"}` | ✅ `{"message": "Hello, Naeel!"}` |
| 6 | terraform apply (pg-query python3.11) | ✅ `{"invocations": [], "count": 0}` через https://sless-api.kube5s.ru |
| 7 | hello-node (nodejs20) E2E | ✅ | `{"message": "Hello, Naeel! (nodejs20)"}` via https://sless-api.kube5s.ru/fn/default/hello-node |
### Terraform провайдер (2026-03-07)
| # | Компонент | Статус | Заметки |
|---|-----------|--------|---------|
| 1 | terraform/provider/ — независимый Go-модуль | ✅ | module: terraform-provider-sless |
| 2 | internal/client/client.go | ✅ | HTTP-клиент sless API, готов к переносу в nubes |
| 3 | internal/provider/provider.go | ✅ | паттерн идентичен nubes NubesProvider |
| 4 | sless_function resource | ✅ | CRUD + upload zip + WaitReady (kaniko) |
| 5 | sless_trigger resource | ✅ | CRUD (http/cron); Delete ждёт исчезновения CR (WaitGone, 90с) |
| 6 | hack/build-and-publish.sh | ✅ | GPG-подпись + mc upload в S3 |
| 7 | Публикация в terra.k8c.ru | ✅ | v0.1.3, key CB3A0DF161ECC416 |
| 8 | terraform init | ✅ | Installed terra.k8c.ru/naeel/sless v0.1.3 |
| 9 | sless_job resource | ✅ | одноразовые запуски функции через FunctionJob CRD |
| 10 | build_timeout_sec / wait_timeout_sec | ✅ | настраиваемые таймауты в sless_function и sless_job |
**Примечание:** Код провайдера писался с прицелом на перенос в nubes провайдер.
`internal/client/` → `internal/core/` nubes, ресурсы → `internal/resources_gen/`.
### source_dir + fix destroy cleanup (2026-03-09)
| # | Компонент | Статус | Заметки |
|---|-----------|--------|----------|
| 1 | provider: source_dir атрибут | ✅ | zip в памяти, SHA256 автоматически; hashicorp/archive не нужен |
| 2 | fix: trigger_controller handleTriggerDeletion | ✅ | удаляет Service+Ingress из sless-fn-{ns} |
| 3 | fix: function_controller handleDeletion | ✅ | удаляет Service+Ingress из sless-fn-{ns} |
| 4 | fix: trigger_resource.go Delete WaitGone | ✅ | polling GetTrigger до 404, таймаут 90с |
| 5 | fix: invoke.go no such host → 404 | ✅ | после destroy DNS NXDOMAIN → 404 вместо 502 |
| 6 | E2E тест run_terraform_examples.sh | ✅ | все 4 примера: hello-node, simple-node, simple-python, notes-python |
### Что ещё не сделано
| # | Компонент | Статус | Заметки |
|---|-----------|--------|---------|
| 1 | terraform apply e2e — функция с postgres | ✅ | `{"invocations": [], "count": 0}` — функция подключилась к postgres.sless.svc, таблица пустая |
| 2 | HTTP trigger e2e тест | ✅ | прокси /fn/ через sless-api, без wildcard DNS |
| 3 | deployments/k8s/operator.yaml | ✅ | Deployment+Service+Ingress, naeel/sless-operator:v0.1.5, Running |
| 4 | Внешний доступ к API оператора | ✅ | https://sless-api.kube5s.ru → HTTP/2 200, TLS cert OK |
| 5 | DNS запись sless-api.kube5s.ru | ✅ | A → 5.172.178.182, TTL 3600, zone kube5s.ru |
| 6 | nodejs20 runtime | ✅ | runtimes/nodejs20/server.js + Dockerfile, naeel/sless-runtime-nodejs20:v0.1.0 |
| 7 | upload.go: nodejs20 + package.json | ✅ | runtimeBaseImage switch, hasPackageJSON → npm install |
| 8 | go.mod: direct/indirect fix | ✅ | go mod tidy — gorilla/mux, lib/pq, minio-go, k8s.io/api помечены direct |
| 9 | Runtime образы: версионированные теги | ✅ | python3.11:v0.1.0, nodejs20:v0.1.0 (убираем :latest) |
---
## v2 — Расширения
| # | Компонент | Статус | Заметки |
|---|-----------|--------|---------|
| 1 | RabbitMQ event triggers | ⏳ | |
| 2 | Keycloak / облачный auth | ⏳ | |
| 0 | Изоляция пользователей по namespace | ⏳ | Каждый токен → свой k8s namespace. API: GET /v1/whoami → {namespace}. Провайдер получает ns при Configure(), юзер не видит. Сейчас: хардкод "default" для демо. |
| 2 | Облачный auth (nubes.ru token validation) | ⏳ | v1 — статический токен |
| 3 | Метрики → Victoria Metrics | ⏳ | |
| 4 | Интеграция с облачными PostgreSQL/Redis | ⏳ | |
| 5 | Мониторинг UI | ⏳ | |
| 4 | Managed PostgreSQL от провайдера | ⏳ | сейчас postgres в k8s |
| 5 | Pre-warm для cron триггеров | ⏳ | TriggerSpec.PreWarmSeconds — поле есть, логика не реализована |
| 11 | trigger.enabled | ✅ | enabled=false → Deployment replicas=0, in-place update через PATCH |
| 12 | job.run_id | ✅ | run_id=0 → skip, run_id>0 → execute. Повторный запуск = увеличить run_id |
+143
View File
@@ -0,0 +1,143 @@
# Bug Report: HTTP route is not removed after Terraform destroy
## Summary
При удалении примера `hello-node` через Terraform команда `terraform destroy` завершается успешно, но публичный HTTP endpoint не удаляется.
Фактическое поведение после `destroy` такое:
1. Сразу после удаления endpoint ещё некоторое время отвечает `HTTP 200` и возвращает корректный ответ функции.
2. Затем backend функции действительно исчезает, но публичный маршрут остаётся опубликованным и начинает отвечать `HTTP 502 function unreachable`.
3. Даже через 120 секунд endpoint не исчезает.
Это выглядит как баг cleanup в platform/backend/provider lifecycle для HTTP trigger/route.
## Affected Example
- Example: `hello-node`
- Terraform files: `hello-node/main.tf`, `hello-node/http.tf`, `hello-node/job.tf`
- Public URL: `https://sless-api.kube5s.ru/fn/default/hello-http`
- Function name: `hello-http`
- Trigger name: `hello-http-trigger`
## Reproduction
Использовался репозиторий examples и скрипт:
- Script: `./run_terraform_examples.sh`
Шаги воспроизведения:
1. Выполнить `terraform init` в `hello-node`
2. Выполнить `terraform apply`
3. Убедиться, что endpoint живой
4. Выполнить `terraform destroy`
5. Проверять публичный URL после destroy
Логика проверки встроена в `run_terraform_examples.sh`:
1. После `apply` endpoint обязан отвечать `200`
2. После `destroy` endpoint должен исчезнуть
3. Скрипт ждёт до 120 секунд и перепроверяет endpoint каждые 5 секунд
## Expected Result
После успешного `terraform destroy`:
1. Публичный URL должен перестать существовать
2. Запрос на URL должен вернуть `404` или другой явный признак отсутствия маршрута
3. Provider не должен возвращать успешный destroy раньше, чем cleanup HTTP route завершён
## Actual Result
После успешного `terraform destroy`:
1. Terraform сообщает `Destroy complete! Resources: 4 destroyed.`
2. Endpoint `https://sless-api.kube5s.ru/fn/default/hello-http` продолжает отвечать `200`
3. Через некоторое время тот же endpoint начинает отвечать `502`
4. Тело ответа на `502`:
```json
{"error":"function unreachable: Post \"http://hello-http.sless-fn-default.svc.cluster.local:8080\": dial tcp 10.106.128.167:8080: connect: operation not permitted"}
```
Это означает:
1. внешний HTTP маршрут всё ещё существует;
2. запрос по нему всё ещё направляется внутрь платформы;
3. backend функции уже удалён или недоступен;
4. cleanup маршрута не завершён.
## Timeline From Real Run
Подтверждённая последовательность из фактического прогона:
1. `terraform destroy` завершился успешно
2. первые проверки после destroy возвращали `HTTP 200`
3. затем проверки начали возвращать `HTTP 502 function unreachable`
4. в течение всех 24 проверок по 5 секунд endpoint не исчез
5. итоговое время ожидания: 120 секунд
Итоговый summary из скрипта:
```text
ERROR SUMMARY
example: hello-node
step: endpoint cleanup after clean destroy
reason: route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable); endpoint was still published after 120s
```
## Why This Is A Real Platform Bug
Это не похоже на проблему тестового скрипта или Terraform CLI по следующим причинам:
1. `terraform destroy` завершается без ошибки
2. state Terraform очищается как ожидалось
3. сначала endpoint отвечает `200`, значит маршрут реально жив после destroy
4. потом endpoint отвечает `502 function unreachable`, значит backend уже исчез, но route ещё остался
5. скрипт ждёт 120 секунд, то есть это не мгновенная eventual consistency на 1-2 секунды
Иными словами: удаление backend и удаление публичного маршрута расходятся по времени, а route cleanup либо не выполняется, либо не дожидается завершения.
## Most Likely Broken Layer
Наиболее вероятные точки проблемы:
1. API/backend destroy trigger возвращает success до фактического удаления HTTP route
2. Controller удаляет function workload, но не удаляет route/ingress/virtualservice/gateway mapping
3. Удаление route запускается асинхронно, но его результат не awaited
4. В системе остаётся запись маршрута на имя функции, хотя service/backend уже удалён
## What To Check In The Development Repo
Нужно проверить destroy flow именно для HTTP trigger:
1. Удаляется ли объект trigger только в metadata/storage или реально удаляется и внешний маршрут
2. Какие Kubernetes/ingress объекты создаются для HTTP trigger и все ли они удаляются
3. Есть ли race condition между удалением function/service и удалением route
4. Не возвращает ли provider success раньше, чем backend подтверждает полное удаление маршрута
5. Есть ли финальный polling/wait на исчезновение route перед возвратом успешного destroy
Если архитектура использует отдельные сущности route/service/function, то destroy должен идти в таком порядке:
1. disable/remove public routing
2. дождаться, что endpoint больше не публикуется снаружи
3. удалить backend/service/workload
4. завершить destroy success
Сейчас по фактическому поведению порядок либо обратный, либо неполный.
## Minimal Acceptance Criteria For Fix
Исправление можно считать рабочим, если после `terraform destroy` для `hello-node` выполняются все условия:
1. URL `https://sless-api.kube5s.ru/fn/default/hello-http` перестаёт отвечать как живой маршрут
2. URL не возвращает `502 function unreachable`
3. URL исчезает в разумное время после destroy
4. `./run_terraform_examples.sh` проходит шаг `endpoint cleanup after clean destroy`
## Current Status
На данный момент массовый прогон examples корректно останавливается на `hello-node`, потому что это первый воспроизводимый failure.
Дальше прогонять остальные примеры без исправления destroy cleanup смысла нет: тест уже доказал platform bug на базовом HTTP сценарии.
+160
View File
@@ -0,0 +1,160 @@
# Примеры sless
## Что такое sless
**sless** — платформа для запуска serverless-функций в Kubernetes-кластере.
Код на Python или Node.js загружается в платформу, которая собирает Docker-образ, деплоит его в кластер и публикует HTTP-эндпоинт. Всё управляется через Terraform.
### Ресурсы
| Ресурс | Что делает |
|---|---|
| `sless_function` | Загружает код и собирает Docker-образ. Сама по себе не принимает запросы — нужен триггер или джоб |
| `sless_trigger` | Публикует функцию — либо как HTTP-эндпоинт, либо по расписанию (cron) |
| `sless_job` | Запускает функцию один раз (например, для инициализации БД) и ждёт результата |
**Типичный сценарий:** `sless_function` с кодом + `sless_trigger` с `type = "http"` → публичный URL вида `https://sless-api.kube5s.ru/fn/default/имя-функции`.
---
Примеры показывают различные сценарии использования serverless функций через Terraform провайдер `terra.k8c.ru/naeel/sless`.
## Требования
- Terraform >= 1.0
- Доступ к `https://sless-api.kube5s.ru`
## Провайдер
Во всех примерах `main.tf` содержит:
```hcl
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
}
```
---
## Примеры
### `simple-python` — джоб передаёт результат в HTTP-функцию (Python)
При `apply` запускается джоб, его вывод передаётся в HTTP-функцию через `env_vars`.
```bash
cd simple-python
terraform init
terraform apply -auto-approve
# Что вернул джоб (время на момент деплоя):
terraform output job_result
# Проверить функцию:
curl -s https://sless-api.kube5s.ru/fn/default/simple-py-time-display
```
---
### `simple-node` — то же самое, но на Node.js 20
```bash
cd simple-node
terraform init
terraform apply -auto-approve
terraform output job_result
curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display
```
---
### `hello-node` — минимальный пример на Node.js
Две независимые функции: HTTP-функция (возвращает приветствие) и одноразовый джоб (суммирует числа).
```bash
cd hello-node
terraform init
terraform apply -auto-approve
# Проверить HTTP-функцию:
curl -s -X POST https://sless-api.kube5s.ru/fn/default/hello-http \
-H 'Content-Type: application/json' -d '{"name":"World"}'
# Посмотреть результат джоба:
terraform output job_message
```
---
### `notes-python` — CRUD API на Python + PostgreSQL
Полноценное приложение: инициализация схемы БД через джобы, CRUD-функция, read-only функция для списка записей.
**Переменные:**
| Переменная | Описание | Дефолт |
|---|---|---|
| `pg_dsn` | DSN для подключения к PostgreSQL | `postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable` |
```bash
cd notes-python
terraform init
# Опционально — переопределить DSN:
# export TF_VAR_pg_dsn="postgres://user:pass@host:5432/db?sslmode=disable"
terraform apply -auto-approve
# Проверить инициализацию БД:
terraform output db_init_table_status
terraform output db_init_index_status
# URL функций:
terraform output notes_url # CRUD
terraform output notes_list_url # список всех записей
# Создать запись:
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/add?title=Hello&body=World"
# Список записей:
curl -s https://sless-api.kube5s.ru/fn/default/notes-list
# Обновить (id из предыдущего ответа):
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body"
# Удалить:
curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1"
```
---
## Общие команды
```bash
# Посмотреть текущее состояние ресурсов:
terraform show
# Пересоздать конкретный ресурс:
terraform apply -replace=sless_function.имя -auto-approve
# Повторно запустить джоб — увеличить run_id в .tf файле, затем:
terraform apply -auto-approve
# Удалить все ресурсы примера:
terraform destroy -auto-approve
```
## Структура каждого примера
```
пример/
├── main.tf — провайдер
├── *.tf — ресурсы (функции, триггеры, джобы)
├── outputs.tf — URLs и статусы после apply
├── variables.tf — входные переменные (если есть)
└── code/ — исходный код функций
```
+8
View File
@@ -0,0 +1,8 @@
// 2026-03-08
// handler-http.js — HTTP-функция: возвращает приветствие.
// Используется с sless_trigger (постоянный эндпоинт).
exports.handle = async (event) => {
const name = event.name || 'World';
return { message: `Hello, ${name}! HTTP !!!` };
};
+11
View File
@@ -0,0 +1,11 @@
// 2026-03-08
// handler-job.js — batch-функция: суммирует числа и считает среднее.
// Используется с sless_job (одноразовый запуск).
// event.numbers — массив чисел, например [1, 2, 3, 4, 5]
exports.handle = async (event) => {
const numbers = event.numbers || [];
const sum = numbers.reduce((acc, n) => acc + n, 0);
const avg = numbers.length > 0 ? sum / numbers.length : 0;
return { input: numbers, sum, avg, count: numbers.length };
};
+24
View File
@@ -0,0 +1,24 @@
# 2026-03-08 / Изменено: 2026-03-09
# http.tf — HTTP-функция: принимает запросы, возвращает приветствие.
# Код: code/handler-http.js
resource "sless_function" "hello_http" {
name = "hello-http"
runtime = "nodejs20"
entrypoint = "handler-http.handle"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code"
}
resource "sless_trigger" "hello_http" {
name = "hello-http-trigger"
type = "http"
function = sless_function.hello_http.name
enabled = true
}
output "trigger_url" {
value = sless_trigger.hello_http.url
}
+34
View File
@@ -0,0 +1,34 @@
# 2026-03-08 / Изменено: 2026-03-09
# job.tf — одноразовая функция: суммирует числа из переданного массива.
# Код: code/handler-job.js
resource "sless_function" "hello_job" {
name = "hello-job"
runtime = "nodejs20"
entrypoint = "handler-job.handle"
memory_mb = 128
timeout_sec = 30
source_dir = "${path.module}/code"
}
# Одноразовый запуск. Для повторного запуска увеличь run_id (1→2→3...).
resource "sless_job" "hello_run" {
name = "hello-run"
function = sless_function.hello_job.name
event_json = jsonencode({ numbers = [100, 200, 300] })
wait_timeout_sec = 600
run_id = 9
}
output "job_phase" {
value = sless_job.hello_run.phase
}
output "job_message" {
value = sless_job.hello_run.message
}
output "job_completion_time" {
value = sless_job.hello_run.completion_time
}
+20
View File
@@ -0,0 +1,20 @@
# 2026-03-08
# main.tf — провайдеры.
# Функции и их код определены в отдельных файлах:
# http.tf — HTTP-триггер (code/handler-http.js)
# job.tf — одноразовый запуск (code/handler-job.js)
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.11"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
}
@@ -0,0 +1,2 @@
# Временный файл для негативных тестов — не применяется через terraform
# Тесты запускаются вручную с временным переименованием в .tf
@@ -0,0 +1,31 @@
# 2026-03-09
# notes_list.py — чтение всех записей из таблицы notes.
#
# Назначение: отдать полный список заметок одним запросом.
# Принимает GET или POST — тело/query параметры игнорируются.
# Возвращает JSON-массив, сортировка: новые записи первые (ORDER BY created_at DESC).
#
# Пример ответа:
# [
# {"id": 3, "title": "Hello", "body": "World", "created_at": "2026-03-09 ..."},
# {"id": 1, "title": "First", "body": "Note", "created_at": "2026-03-08 ..."}
# ]
import os
import psycopg2
import psycopg2.extras
def list_notes(event):
dsn = os.environ['PG_DSN']
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"SELECT id, title, body, created_at::text FROM notes ORDER BY created_at DESC"
)
rows = cur.fetchall()
return [dict(r) for r in rows]
except Exception as e:
return {'error': str(e)}
finally:
conn.close()
@@ -0,0 +1 @@
psycopg2-binary
@@ -0,0 +1,81 @@
# 2026-03-09
# notes_crud.py — CRUD роутер для таблицы notes.
#
# Назначение: единая функция, которая обрабатывает все операции с записями.
# Роутинг осуществляется по sub-path URL (event._path), который runtime
# берёт из входящего HTTP-запроса и добавляет в event автоматически.
#
# Доступные маршруты (все POST):
# /fn/default/notes/add?title=...&body=... → создать запись
# /fn/default/notes/update?id=1&title=...&body=... → обновить запись
# /fn/default/notes/delete?id=1 → удалить запись
#
# Параметры берутся из query string (event._query) или из тела запроса (event).
# event._path и event._query добавляет Python runtime (server.py) автоматически.
import os
import psycopg2
import psycopg2.extras
def crud(event):
dsn = os.environ['PG_DSN']
# sub-path без ведущего слэша: "add", "update", "delete"
action = event.get('_path', '/').strip('/')
q = event.get('_query', {})
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
if action == 'add':
title = q.get('title') or event.get('title', '')
body = q.get('body') or event.get('body', '')
if not title:
return {'error': 'title is required'}
cur.execute(
"INSERT INTO notes (title, body) VALUES (%s, %s)"
" RETURNING id, title, body, created_at::text",
(title, body)
)
row = cur.fetchone()
conn.commit()
return dict(row)
elif action == 'update':
id_ = q.get('id') or event.get('id')
if not id_:
return {'error': 'id is required'}
title = q.get('title') or event.get('title', '')
body = q.get('body') or event.get('body', '')
cur.execute(
"UPDATE notes SET title=%s, body=%s WHERE id=%s"
" RETURNING id, title, body, created_at::text",
(title, body, int(id_))
)
row = cur.fetchone()
conn.commit()
return dict(row) if row else {'error': 'not found'}
elif action == 'delete':
id_ = q.get('id') or event.get('id')
if not id_:
return {'error': 'id is required'}
cur.execute(
"DELETE FROM notes WHERE id=%s RETURNING id",
(int(id_),)
)
row = cur.fetchone()
conn.commit()
return {'deleted': row['id']} if row else {'error': 'not found'}
else:
return {
'error': f'unknown action: /{action}',
'hint': 'use /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X'
}
except Exception as e:
conn.rollback()
return {'error': str(e)}
finally:
conn.close()
@@ -0,0 +1 @@
psycopg2-binary
@@ -0,0 +1 @@
psycopg2-binary
@@ -0,0 +1,39 @@
# 2026-03-09
# sql_runner.py — универсальный DDL/SQL исполнитель.
#
# Назначение: выполнять произвольные SQL запросы переданные через event.
# Используется ТОЛЬКО через sless_job (init.tf) — HTTP-триггера нет намеренно,
# чтобы никто снаружи не мог выполнить произвольный SQL.
#
# Входящий event:
# {
# "statements": [
# "CREATE TABLE IF NOT EXISTS ...",
# "CREATE INDEX IF NOT EXISTS ..."
# ]
# }
#
# Все statements выполняются последовательно в одной транзакции.
# Если хотя бы один упал — транзакция откатывается целиком.
import os
import psycopg2
def run_sql(event):
dsn = os.environ['PG_DSN']
statements = event.get('statements', [])
if not statements:
return {'error': 'no statements provided'}
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
for sql in statements:
cur.execute(sql)
conn.commit()
return {'ok': True, 'executed': len(statements)}
except Exception as e:
conn.rollback()
return {'error': str(e)}
finally:
conn.close()
+44
View File
@@ -0,0 +1,44 @@
# 2026-03-09
# init.tf — однократная инициализация схемы БД через sless_job.
#
# Джобы запускаются один раз при terraform apply и ждут завершения.
# Использует функцию sql_runner (без HTTP-триггера) для безопасного DDL.
#
# Порядок выполнения гарантирован через depends_on:
# 1. notes_table_init — создаём таблицу
# 2. notes_index_init — создаём индекс (требует таблицу)
#
# Для повторного запуска (например, после DROP TABLE) — увеличь run_id.
# run_id отслеживается в state: при изменении terraform перезапустит джоб.
# Джоб создания таблицы notes.
# CREATE TABLE IF NOT EXISTS — безопасно запускать повторно, таблица не пересоздаётся.
resource "sless_job" "notes_table_init" {
name = "notes-create-table"
function = sless_function.sql_runner.name
wait_timeout_sec = 120
run_id = 1
event_json = jsonencode({
statements = [
"CREATE TABLE IF NOT EXISTS notes (id serial PRIMARY KEY, title text NOT NULL, body text, created_at timestamp DEFAULT now())"
]
})
}
# Джоб создания индекса для сортировки по дате.
# depends_on гарантирует, что таблица уже создана до создания индекса.
resource "sless_job" "notes_index_init" {
depends_on = [sless_job.notes_table_init]
name = "notes-create-index"
function = sless_function.sql_runner.name
wait_timeout_sec = 60
run_id = 1
event_json = jsonencode({
statements = [
"CREATE INDEX IF NOT EXISTS notes_created_idx ON notes(created_at DESC)"
]
})
}
+26
View File
@@ -0,0 +1,26 @@
# 2026-03-09
# main.tf — конфигурация terraform и провайдеров.
#
# Все ресурсы вынесены в отдельные .tf файлы по назначению:
# variables.tf — входные переменные (pg_dsn)
# sql-runner.tf — служебная DDL-функция (без HTTP-триггера)
# init.tf — однократная инициализация схемы БД
# notes.tf — CRUD функция + HTTP-триггер
# notes-list.tf — read-only функция + HTTP-триггер
# outputs.tf — URLs развёрнутых эндпоинтов
terraform {
required_providers {
# Провайдер для управления serverless функциями через sless API
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.11"
}
}
}
# sless провайдер подключается к API кластера.
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
}
+22
View File
@@ -0,0 +1,22 @@
# 2026-03-09
# notes-list.tf — read-only эндпоинт: возвращает все заметки, сортировка новые первые.
resource "sless_function" "notes_list" {
name = "notes-list"
runtime = "python3.11"
entrypoint = "notes_list.list_notes"
memory_mb = 128
timeout_sec = 30
env_vars = {
PG_DSN = var.pg_dsn
}
source_dir = "${path.module}/code/notes-list"
}
resource "sless_trigger" "notes_list_http" {
name = "notes-list-http"
type = "http"
function = sless_function.notes_list.name
}
+27
View File
@@ -0,0 +1,27 @@
# 2026-03-09
# notes.tf — CRUD функция для управления заметками (CREATE / UPDATE / DELETE).
#
# Маршруты (рекомендуется POST):
# /fn/default/notes/add?title=...&body=... → INSERT
# /fn/default/notes/update?id=1&title=...&body=... → UPDATE
# /fn/default/notes/delete?id=1 → DELETE
resource "sless_function" "notes_crud" {
name = "notes"
runtime = "python3.11"
entrypoint = "notes_crud.crud"
memory_mb = 128
timeout_sec = 30
env_vars = {
PG_DSN = var.pg_dsn
}
source_dir = "${path.module}/code/notes"
}
resource "sless_trigger" "notes_crud_http" {
name = "notes-http"
type = "http"
function = sless_function.notes_crud.name
}
+42
View File
@@ -0,0 +1,42 @@
# 2026-03-09
# outputs.tf — публичные URL развёрнутых функций.
#
# После terraform apply используй эти URLs для тестирования:
# terraform output notes_url → базовый URL для CRUD
# terraform output notes_list_url → URL для получения всех записей
# URL CRUD-функции (notes_crud).
# Базовый URL — к нему добавляй sub-path:
# POST $(terraform output -raw notes_url)/add?title=Hello&body=World
# POST $(terraform output -raw notes_url)/update?id=1&title=Updated
# POST $(terraform output -raw notes_url)/delete?id=1
output "notes_url" {
value = sless_trigger.notes_crud_http.url
description = "CRUD: /add?title=...&body=..., /update?id=X&title=...&body=..., /delete?id=X"
}
# URL read-only функции (notes_list).
# Принимает GET или POST, параметры игнорирует, возвращает все записи.
output "notes_list_url" {
value = sless_trigger.notes_list_http.url
description = "Список всех записей (GET или POST)"
}
# Статус init-джобов — показывает результат инициализации БД.
# Если phase="Succeeded" — таблица и индекс созданы успешно.
# Если phase="Failed" — смотри message, исправь и увеличь run_id в init.tf.
output "db_init_table_status" {
value = {
phase = sless_job.notes_table_init.phase
message = sless_job.notes_table_init.message
}
description = "Статус джоба создания таблицы notes"
}
output "db_init_index_status" {
value = {
phase = sless_job.notes_index_init.phase
message = sless_job.notes_index_init.message
}
description = "Статус джоба создания индекса"
}
+20
View File
@@ -0,0 +1,20 @@
# 2026-03-09
# sql-runner.tf — служебная DDL-функция для инициализации и миграций БД.
#
# ВАЖНО: эта функция не имеет HTTP-триггера — только вызов через sless_job.
# Это сделано намеренно: функция выполняет произвольный SQL, и открывать её
# наружу через HTTP было бы небезопасно.
resource "sless_function" "sql_runner" {
name = "sql-runner"
runtime = "python3.11"
entrypoint = "sql_runner.run_sql"
memory_mb = 128
timeout_sec = 30
env_vars = {
PG_DSN = var.pg_dsn
}
source_dir = "${path.module}/code/sql-runner"
}
+15
View File
@@ -0,0 +1,15 @@
# 2026-03-09
# variables.tf — входные переменные для notes-python примера.
#
# PG_DSN передаётся во все функции через env_vars.
# Хранится как sensitive чтобы не светился в terraform output и логах.
# В продакшне — не хардкоди DSN здесь, используй TF_VAR_pg_dsn или secrets manager.
# DSN для подключения к PostgreSQL внутри кластера.
# Формат: postgres://user:password@host:port/dbname?sslmode=...
variable "pg_dsn" {
description = "PostgreSQL DSN для подключения к БД внутри кластера"
type = string
default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable"
sensitive = true
}
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LOG_DIR="$ROOT_DIR/.test-logs"
mkdir -p "$LOG_DIR"
EXAMPLES=(
"hello-node"
"simple-node"
"simple-python"
"notes-python"
)
declare -A CHECK_METHOD=(
[hello-node]="POST"
[simple-node]="GET"
[simple-python]="GET"
[notes-python]="GET"
)
declare -A CHECK_URL=(
[hello-node]="https://sless-api.kube5s.ru/fn/default/hello-http"
[simple-node]="https://sless-api.kube5s.ru/fn/default/simple-node-time-display"
[simple-python]="https://sless-api.kube5s.ru/fn/default/simple-py-time-display"
[notes-python]="https://sless-api.kube5s.ru/fn/default/notes-list"
)
declare -A CHECK_DATA=(
[hello-node]='{"name":"Smoke"}'
[simple-node]=''
[simple-python]=''
[notes-python]=''
)
declare -a PREEXISTING_EXAMPLES=()
LAST_LOG_FILE=""
CURRENT_EXAMPLE=""
CURRENT_STEP=""
LAST_ERROR_SUMMARY=""
fail_run() {
local example="$1"
local step="$2"
local details="$3"
echo
echo "ERROR SUMMARY"
echo "example: $example"
echo "step: $step"
echo "reason: $details"
if [ -n "$LAST_LOG_FILE" ] && [ -f "$LAST_LOG_FILE" ]; then
echo "log: $LAST_LOG_FILE"
echo "last log lines:"
tail -n 20 "$LAST_LOG_FILE"
fi
exit 1
}
run_step() {
local example="$1"
local step="$2"
shift 2
CURRENT_EXAMPLE="$example"
CURRENT_STEP="$step"
LAST_ERROR_SUMMARY=""
if ! "$@"; then
local details="$LAST_ERROR_SUMMARY"
if [ -z "$details" ]; then
details="step failed without explicit summary"
fi
fail_run "$example" "$step" "$details"
fi
}
restore_any_backups() {
local backup
while IFS= read -r backup; do
[ -n "$backup" ] || continue
if [ -f "$backup" ]; then
mv "$backup" "${backup%.copilot.bak}"
fi
done < <(find "$ROOT_DIR" -name '*.copilot.bak' | sort)
}
trap restore_any_backups EXIT
clean_local_artifacts() {
local example="$1"
rm -rf \
"$ROOT_DIR/$example/.terraform" \
"$ROOT_DIR/$example/.terraform.lock.hcl" \
"$ROOT_DIR/$example/terraform.tfstate" \
"$ROOT_DIR/$example/terraform.tfstate.backup" \
"$ROOT_DIR/$example"/terraform.tfstate.*.backup \
"$ROOT_DIR/$example/dist"
}
retry_tf() {
local example="$1"
local label="$2"
shift 2
local attempt=1
while [ "$attempt" -le 3 ]; do
LAST_LOG_FILE="$LOG_DIR/${example//\//_}-${label// /_}-${attempt}.log"
echo "==> [$example] $label (attempt $attempt/3)"
(
cd "$ROOT_DIR/$example"
"$@"
) 2>&1 | tee "$LAST_LOG_FILE"
local status=${PIPESTATUS[0]}
if [ "$status" -eq 0 ]; then
return 0
fi
if grep -Eiq 'Unauthorized|401|403' "$LAST_LOG_FILE"; then
LAST_ERROR_SUMMARY="authorization error during $label"
echo "[$example] authorization error during $label"
return 41
fi
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE" && [ "$attempt" -lt 3 ]; then
attempt=$((attempt + 1))
sleep 2
continue
fi
if grep -Eiq 'TLS handshake timeout|tls:.*timeout|i/o timeout|Client\.Timeout exceeded while awaiting headers|context deadline exceeded|unexpected EOF' "$LAST_LOG_FILE"; then
LAST_ERROR_SUMMARY="network/provider download failure during $label after retries"
else
LAST_ERROR_SUMMARY="terraform command failed during $label with exit code $status"
fi
return "$status"
done
LAST_ERROR_SUMMARY="terraform command failed during $label after exhausting retries"
return 1
}
record_preexisting_if_needed() {
local example="$1"
if grep -Fq 'No changes. Your infrastructure matches the configuration.' "$LAST_LOG_FILE"; then
PREEXISTING_EXAMPLES+=("$example")
echo "[$example] detected preexisting remote resources on clean apply"
fi
}
probe_endpoint() {
local example="$1"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status_file="$LOG_DIR/${example//\//_}-endpoint-status.txt"
local method="${CHECK_METHOD[$example]}"
local url="${CHECK_URL[$example]}"
local data="${CHECK_DATA[$example]}"
if [ "$method" = "POST" ]; then
curl -sS -X POST -H 'Content-Type: application/json' -d "$data" -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
else
curl -sS -o "$body_file" -w '%{http_code}' "$url" > "$status_file"
fi
}
assert_live_endpoint() {
local example="$1"
probe_endpoint "$example"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
if [ "$status" != "200" ]; then
LAST_ERROR_SUMMARY="live endpoint check failed with HTTP $status"
echo "[$example] live endpoint check failed with HTTP $status"
cat "$body_file"
return 1
fi
if grep -Fq 'function unreachable' "$body_file"; then
LAST_ERROR_SUMMARY="live endpoint returned function unreachable"
echo "[$example] live endpoint check returned unreachable function"
cat "$body_file"
return 1
fi
}
assert_destroyed_endpoint() {
local example="$1"
probe_endpoint "$example"
local body_file="$LOG_DIR/${example//\//_}-endpoint-body.txt"
local status
status="$(cat "$LOG_DIR/${example//\//_}-endpoint-status.txt")"
if [ "$status" = "404" ] || [ "$status" = "000" ]; then
return 0
fi
if grep -Eiq 'not found|404 page not found' "$body_file"; then
return 0
fi
if [ "$status" = "502" ] && grep -Fq 'function unreachable' "$body_file"; then
LAST_ERROR_SUMMARY="route cleanup bug: public endpoint still exists but backend is already gone (HTTP 502 function unreachable)"
echo "[$example] route still exists after destroy, but backend is already gone (HTTP 502 function unreachable)"
cat "$body_file"
return 1
fi
LAST_ERROR_SUMMARY="endpoint still responds after destroy with HTTP $status"
echo "[$example] endpoint still responds after destroy with HTTP $status"
cat "$body_file"
return 1
}
wait_for_destroyed_endpoint() {
local example="$1"
local attempts=24
local sleep_sec=5
local try=1
while [ "$try" -le "$attempts" ]; do
if assert_destroyed_endpoint "$example"; then
echo "[$example] endpoint disappeared after destroy"
return 0
fi
echo "[$example] endpoint still present after destroy, waiting (${try}/${attempts})"
try=$((try + 1))
sleep "$sleep_sec"
done
echo "[$example] endpoint did not disappear after destroy within $((attempts * sleep_sec))s"
if [ -z "$LAST_ERROR_SUMMARY" ]; then
LAST_ERROR_SUMMARY="endpoint remained reachable for more than $((attempts * sleep_sec))s after destroy"
else
LAST_ERROR_SUMMARY="$LAST_ERROR_SUMMARY; endpoint was still published after $((attempts * sleep_sec))s"
fi
return 1
}
backup_and_modify() {
local example="$1"
case "$example" in
hello-node)
cp "$ROOT_DIR/$example/http.tf" "$ROOT_DIR/$example/http.tf.copilot.bak"
perl -0pi -e 's/enabled\s+=\s+true/enabled = false/' "$ROOT_DIR/$example/http.tf"
;;
simple-node)
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
;;
simple-python)
cp "$ROOT_DIR/$example/time-display.tf" "$ROOT_DIR/$example/time-display.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+64/memory_mb = 96/' "$ROOT_DIR/$example/time-display.tf"
;;
notes-python)
cp "$ROOT_DIR/$example/notes-list.tf" "$ROOT_DIR/$example/notes-list.tf.copilot.bak"
perl -0pi -e 's/memory_mb\s+=\s+128/memory_mb = 160/' "$ROOT_DIR/$example/notes-list.tf"
;;
esac
}
restore_modified_files() {
local example="$1"
case "$example" in
hello-node)
mv "$ROOT_DIR/$example/http.tf.copilot.bak" "$ROOT_DIR/$example/http.tf"
;;
simple-node)
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
;;
simple-python)
mv "$ROOT_DIR/$example/time-display.tf.copilot.bak" "$ROOT_DIR/$example/time-display.tf"
;;
notes-python)
mv "$ROOT_DIR/$example/notes-list.tf.copilot.bak" "$ROOT_DIR/$example/notes-list.tf"
;;
esac
}
run_example() {
local example="$1"
echo
echo "==== $example ===="
clean_local_artifacts "$example"
run_step "$example" "terraform init" retry_tf "$example" "terraform init" terraform init -input=false -no-color
run_step "$example" "terraform apply clean" retry_tf "$example" "terraform apply clean" terraform apply -auto-approve -input=false -no-color
record_preexisting_if_needed "$example"
run_step "$example" "endpoint check after clean apply" assert_live_endpoint "$example"
run_step "$example" "terraform destroy clean" retry_tf "$example" "terraform destroy clean" terraform destroy -auto-approve -input=false -no-color
run_step "$example" "endpoint cleanup after clean destroy" wait_for_destroyed_endpoint "$example"
run_step "$example" "terraform apply second" retry_tf "$example" "terraform apply second" terraform apply -auto-approve -input=false -no-color
run_step "$example" "endpoint check after second apply" assert_live_endpoint "$example"
backup_and_modify "$example"
run_step "$example" "terraform apply modified" retry_tf "$example" "terraform apply modified" terraform apply -auto-approve -input=false -no-color
restore_modified_files "$example"
run_step "$example" "terraform destroy final" retry_tf "$example" "terraform destroy final" terraform destroy -auto-approve -input=false -no-color
run_step "$example" "endpoint cleanup after final destroy" wait_for_destroyed_endpoint "$example"
clean_local_artifacts "$example"
}
main() {
local example
for example in "${EXAMPLES[@]}"; do
run_example "$example"
done
if [ "${#PREEXISTING_EXAMPLES[@]}" -gt 0 ]; then
echo
echo "Preexisting remote resources were detected on first apply for: ${PREEXISTING_EXAMPLES[*]}"
exit 2
fi
echo
echo "All Terraform example lifecycles completed successfully."
}
main "$@"
@@ -0,0 +1,20 @@
// Создано: 2026-03-09
// time_display.js — HTTP-функция (постоянный Deployment + Trigger).
// Читает env JOB_TIME, которую terraform передаёт из sless_job.run_getter.message.
// Демонстрирует цепочку: Job вычисляет данные → Function использует их через env.
exports.showTime = function(event) {
// JOB_TIME устанавливается terraform из статуса джоба (JSON строка)
const jobTimeRaw = process.env.JOB_TIME || '{}';
let jobTime;
try {
const parsed = JSON.parse(jobTimeRaw);
jobTime = parsed.time || jobTimeRaw;
} catch (e) {
jobTime = jobTimeRaw;
}
return {
message: `Сервис запустился в: ${jobTime}`,
path: event._path || '/',
};
};
@@ -0,0 +1,10 @@
// Создано: 2026-03-09
// time_getter.js — функция запускается как Job (одноразово).
// Возвращает JSON со временем запуска. Оператор v0.1.16 захватывает stdout
// и записывает его в sless_job.run_getter.message — оттуда terraform передаёт
// значение в sless_function.display через env_var JOB_TIME.
exports.getTime = function(event) {
// Возвращаем время в ISO 8601 UTC — без зависимостей, только stdlib
return { time: new Date().toISOString() };
};
+30
View File
@@ -0,0 +1,30 @@
# Создано: 2026-03-09
# main.tf — пример: запустить один раз скрипт при деплое и передать его результат в функцию.
# То же самое что simple-python, но на Node.js 20.
#
# Как это работает:
# 1. При «terraform apply» запускается скрипт-джоб (time_getter)
# 2. Скрипт возвращает JSON с текущим временем
# 3. Terraform подхватывает этот JSON и передаёт в переменную окружения HTTP-функции (time_display)
# 4. Функция отдаёт время при каждом запросе
#
# Зачем такое нужно:
# Если данные нужны функции, но считаются один раз при деплое —
# напишите логику в джоб, а результат передайте через env_vars.
# Например: получить токен, версию схемы БД, время деплоя и т.д.
#
# namespace захардкодирован внутри провайдера, здесь ничего указывать.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.11"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
}
+14
View File
@@ -0,0 +1,14 @@
# Создано: 2026-03-09
# outputs.tf — что выводит terraform после apply.
# Адрес вашей функции — откройте в браузере или вставьте в curl
output "display_url" {
description = "URL функции time_display"
value = sless_trigger.display_http.url
}
# Что вернул скрипт-джоб — именно это передано в функцию как JOB_TIME
output "job_result" {
description = "Результат выполнения скрипта time_getter"
value = sless_job.run_getter.message
}
+28
View File
@@ -0,0 +1,28 @@
# Создано: 2026-03-09 / Изменено: 2026-03-09
# time-display.tf — HTTP-функция, доступная по URL после apply.
# Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME.
# HTTP-функция — отвечает на запросы по URL из outputs.tf
resource "sless_function" "time_display" {
name = "simple-node-time-display" # уникальное имя в namespace
runtime = "nodejs20"
entrypoint = "time_display.showTime" # файл.функция в code/time_display/
memory_mb = 64
# Передаём результат джоба в функцию через переменную окружения.
# В коде функции: process.env.JOB_TIME
env_vars = {
JOB_TIME = sless_job.run_getter.message
}
source_dir = "${path.module}/code/time_display"
depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции
}
# Публикуем функцию по HTTP — URL будет в outputs.tf
resource "sless_trigger" "display_http" {
name = "simple-node-display-http"
type = "http"
function = sless_function.time_display.name
}
+27
View File
@@ -0,0 +1,27 @@
# Создано: 2026-03-09 / Изменено: 2026-03-09
# time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply.
# После запуска его результат доступен через: sless_job.run_getter.message
# Смотри time-display.tf — там этот результат передаётся в функцию.
# Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже
resource "sless_function" "time_getter" {
name = "simple-node-time-getter" # уникальное имя в namespace
runtime = "nodejs20"
entrypoint = "time_getter.getTime" # файл.функция в code/time_getter/
memory_mb = 64
source_dir = "${path.module}/code/time_getter"
}
# Джоб — запускает функцию time_getter один раз прямо при apply.
# run_id = 1 означает «запустить». Если увеличить (2, 3...) — запустится снова.
# После завершения: sless_job.run_getter.message = то что вернула функция
resource "sless_job" "run_getter" {
name = "simple-node-getter-run"
function = sless_function.time_getter.name
run_id = 1
wait_timeout_sec = 120 # сколько секунд ждать завершения скрипта
event_json = "{}" # входные данные для скрипта (пусто — данные не нужны)
depends_on = [sless_function.time_getter]
}
@@ -0,0 +1,20 @@
# Создано: 2026-03-09
# time_display.py — HTTP-функция (постоянный Deployment + Trigger).
# Читает env JOB_TIME, которую terraform передаёт из sless_job.run_getter.message.
# Демонстрирует цепочку: Job вычисляет данные → Function использует их через env.
import json
import os
def show_time(event):
# JOB_TIME устанавливается terraform из статуса джоба (JSON строка)
job_time_raw = os.environ.get("JOB_TIME", "{}")
try:
job_time = json.loads(job_time_raw).get("time", job_time_raw)
except (json.JSONDecodeError, AttributeError):
job_time = job_time_raw
return {
"message": f"Сервис запустился в: {job_time}",
"path": event.get("_path", "/"),
}
@@ -0,0 +1,18 @@
# Создано: 2026-03-09
# time_getter.py — функция запускается как Job (одноразово).
# Возвращает JSON со временем запуска. Оператор v0.1.16 захватывает stdout
# и записывает его в sless_job.run_getter.message — оттуда terraform передаёт
# значение в sless_function.display через env_var JOB_TIME.
import json
from datetime import datetime, timezone
def get_time(event):
# Возвращаем время в ISO 8601 UTC — без зависимостей, только stdlib
return {"time": datetime.now(timezone.utc).isoformat()}
if __name__ == "__main__":
# Для локального тестирования без оператора
print(json.dumps(get_time({})))
+29
View File
@@ -0,0 +1,29 @@
# Создано: 2026-03-09
# main.tf — пример: запустить один раз скрипт при деплое и передать его результат в функцию.
#
# Как это работает:
# 1. При «terraform apply» запускается скрипт-джоб (time_getter)
# 2. Скрипт возвращает JSON с текущим временем
# 3. Terraform подхватывает этот JSON и передаёт в переменную окружения HTTP-функции (time_display)
# 4. Функция отдаёт время при каждом запросе
#
# Зачем такое нужно:
# Если данные нужны функции, но считаются один раз при деплое —
# напишите логику в джоб, а результат передайте через env_vars.
# Например: получить токен, версию схемы БД, время деплоя и т.д.
#
# namespace захардкодирован внутри провайдера, здесь ничего указывать.
terraform {
required_providers {
sless = {
source = "terra.k8c.ru/naeel/sless"
version = "~> 0.1.11"
}
}
}
provider "sless" {
endpoint = "https://sless-api.kube5s.ru"
token = "dev-token-change-me"
}
+14
View File
@@ -0,0 +1,14 @@
# Создано: 2026-03-09
# outputs.tf — что выводит terraform после apply.
# Адрес вашей функции — откройте в браузере или вставьте в curl
output "display_url" {
description = "URL функции time_display"
value = sless_trigger.display_http.url
}
# Что вернул скрипт-джоб — именно это передано в функцию как JOB_TIME
output "job_result" {
description = "Результат выполнения скрипта time_getter"
value = sless_job.run_getter.message
}
+28
View File
@@ -0,0 +1,28 @@
# Создано: 2026-03-09 / Изменено: 2026-03-09
# time-display.tf — HTTP-функция, доступная по URL после apply.
# Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME.
# HTTP-функция — отвечает на запросы по URL из outputs.tf
resource "sless_function" "time_display" {
name = "simple-py-time-display" # уникальное имя в namespace
runtime = "python3.11"
entrypoint = "time_display.show_time" # файл.функция в code/time_display/
memory_mb = 64
# Передаём результат джоба в функцию через переменную окружения.
# В коде функции: os.environ.get("JOB_TIME")
env_vars = {
JOB_TIME = sless_job.run_getter.message
}
source_dir = "${path.module}/code/time_display"
depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции
}
# Публикуем функцию по HTTP — URL будет в outputs.tf
resource "sless_trigger" "display_http" {
name = "simple-py-display-http"
type = "http"
function = sless_function.time_display.name
}
+27
View File
@@ -0,0 +1,27 @@
# Создано: 2026-03-09 / Изменено: 2026-03-09
# time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply.
# После запуска его результат доступен через: sless_job.run_getter.message
# Смотри time-display.tf — там этот результат передаётся в функцию.
# Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже
resource "sless_function" "time_getter" {
name = "simple-py-time-getter" # уникальное имя в namespace
runtime = "python3.11"
entrypoint = "time_getter.get_time" # файл.функция в code/time_getter/
memory_mb = 64
source_dir = "${path.module}/code/time_getter"
}
# Джоб — запускает функцию time_getter один раз прямо при apply.
# run_id = 1 означает «запустить». Если увеличить (2, 3...) — запустится снова.
# После завершения: sless_job.run_getter.message = то что вернула функция
resource "sless_job" "run_getter" {
name = "simple-py-getter-run"
function = sless_function.time_getter.name
run_id = 1
wait_timeout_sec = 120 # сколько секунд ждать завершения скрипта
event_json = "{}" # входные данные для скрипта (пусто — данные не нужны)
depends_on = [sless_function.time_getter]
}
+85
View File
@@ -0,0 +1,85 @@
module gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless
go 1.25
require (
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.11.2
github.com/minio/minio-go/v7 v7.0.99
github.com/onsi/ginkgo/v2 v2.6.0
github.com/onsi/gomega v1.24.1
k8s.io/api v0.26.0
k8s.io/apimachinery v0.26.0
k8s.io/client-go v0.26.0
sigs.k8s.io/controller-runtime v0.14.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.9.0 // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/zapr v1.2.3 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/swag v0.19.14 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/gnostic v0.5.7-v3refs // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/gofuzz v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.37.0 // indirect
github.com/prometheus/procfs v0.8.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.24.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.3.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/apiextensions-apiserver v0.26.0 // indirect
k8s.io/component-base v0.26.0 // indirect
k8s.io/klog/v2 v2.80.1 // indirect
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 // indirect
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 // indirect
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
+649
View File
@@ -0,0 +1,649 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emicklei/go-restful/v3 v3.9.0 h1:XwGDlfxEnQZzuopoqxwSEllNcCOM9DhhFyhFIIGKwxE=
github.com/emicklei/go-restful/v3 v3.9.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH5pOlLGNtQ5lPWQu84=
github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/zapr v1.2.3 h1:a9vnzlIBPQBBkeaR9IuMUfmVOrQlkoC4YfPoFkX3T7A=
github.com/go-logr/zapr v1.2.3/go.mod h1:eIauM6P8qSvTw5o2ez6UEAfGjQKrxQTl5EoK+Qa2oG4=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.14 h1:gm3vOOXfiuw5i9p5N9xJvfjvuofpyvLA9Wr6QfK5Fng=
github.com/go-openapi/swag v0.19.14/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/gnostic v0.5.7-v3refs h1:FhTMOKj2VhjpouxvWJAV1TL304uMlb9zcDqkl6cEI54=
github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/matttproud/golang_protobuf_extensions v1.0.2 h1:hAHbPm5IJGijwng3PWk09JkG9WeqChjprR5s9bBZ+OM=
github.com/matttproud/golang_protobuf_extensions v1.0.2/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.99 h1:2vH/byrwUkIpFQFOilvTfaUpvAX3fEFhEzO+DR3DlCE=
github.com/minio/minio-go/v7 v7.0.99/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/onsi/ginkgo/v2 v2.6.0 h1:9t9b9vRUbFq3C4qKFCGkVuq/fIHji802N1nrtkh1mNc=
github.com/onsi/ginkgo/v2 v2.6.0/go.mod h1:63DOGlLAH8+REH8jUGdL3YpCpu7JODesutUjdENfUAc=
github.com/onsi/gomega v1.24.1 h1:KORJXNNTzJXzu4ScJWssJfJMnJ+2QJqhoQSRwNlze9E=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4=
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b h1:clP8eMhB30EHdc0bd2Twtq6kgU7yl5ub2cQLSdrv1Dg=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gomodules.xyz/jsonpatch/v2 v2.2.0 h1:4pT439QV83L+G9FkcCriY6EkpcK6r6bK+A5FBUMI7qY=
gomodules.xyz/jsonpatch/v2 v2.2.0/go.mod h1:WXp+iVDkoLQqPudfQ9GBlwB2eZ5DKOnjQZCYdOS8GPY=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201019141844-1ed22bb0c154/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.26.0 h1:IpPlZnxBpV1xl7TGk/X6lFtpgjgntCg8PJ+qrPHAC7I=
k8s.io/api v0.26.0/go.mod h1:k6HDTaIFC8yn1i6pSClSqIwLABIcLV9l5Q4EcngKnQg=
k8s.io/apiextensions-apiserver v0.26.0 h1:Gy93Xo1eg2ZIkNX/8vy5xviVSxwQulsnUdQ00nEdpDo=
k8s.io/apiextensions-apiserver v0.26.0/go.mod h1:7ez0LTiyW5nq3vADtK6C3kMESxadD51Bh6uz3JOlqWQ=
k8s.io/apimachinery v0.26.0 h1:1feANjElT7MvPqp0JT6F3Ss6TWDwmcjLypwoPpEf7zg=
k8s.io/apimachinery v0.26.0/go.mod h1:tnPmbONNJ7ByJNz9+n9kMjNP8ON+1qoAIIC70lztu74=
k8s.io/client-go v0.26.0 h1:lT1D3OfO+wIi9UFolCrifbjUUgu7CpLca0AD8ghRLI8=
k8s.io/client-go v0.26.0/go.mod h1:I2Sh57A79EQsDmn7F7ASpmru1cceh3ocVT9KlX2jEZg=
k8s.io/component-base v0.26.0 h1:0IkChOCohtDHttmKuz+EP3j3+qKmV55rM9gIFTXA7Vs=
k8s.io/component-base v0.26.0/go.mod h1:lqHwlfV1/haa14F/Z5Zizk5QmzaVf23nQzCwVOQpfC8=
k8s.io/klog/v2 v2.80.1 h1:atnLQ121W371wYYFawwYx1aEY2eUfs4l3J72wtgAwV4=
k8s.io/klog/v2 v2.80.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280 h1:+70TFaan3hfJzs+7VK2o+OGxg8HsuBr/5f6tVAjDu6E=
k8s.io/kube-openapi v0.0.0-20221012153701-172d655c2280/go.mod h1:+Axhij7bCpeqhklhUTe3xmOn6bWxolyZEeyaFpjGtl4=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448 h1:KTgPnR10d5zhztWptI952TNtt/4u5h3IzDXkdIMuo2Y=
k8s.io/utils v0.0.0-20221128185143-99ec85e7a448/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/controller-runtime v0.14.1 h1:vThDes9pzg0Y+UbCPY3Wj34CGIYPgdmspPm2GIpxpzM=
sigs.k8s.io/controller-runtime v0.14.1/go.mod h1:GaRkrY8a7UZF0kqFFbUKG7n9ICiTY5T55P1RiE3UZlU=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k=
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
+15
View File
@@ -0,0 +1,15 @@
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

Some files were not shown because too many files have changed in this diff Show More