From 8dd5b676c07b9f603145f55774c3c306a20cccf9 Mon Sep 17 00:00:00 2001 From: Naeel Date: Sat, 14 Mar 2026 18:16:29 +0300 Subject: [PATCH] feat: demo managed functions with harbor-backed operator setup --- deployments/k8s/nodered.yaml | 122 +++++++++++++++++ deployments/k8s/operator-demo.yaml | 125 ++++++++++++++++++ deployments/k8s/rabbitmq.yaml | 73 ++++++++++ .../event-cleaner/code/handler.py | 45 +++++++ .../event-monitor/code/handler.py | 47 +++++++ .../event-writer/code/handler.py | 49 +++++++ .../code/event-cleaner/handler.py | 26 ++++ .../code/event-monitor/handler.py | 25 ++++ .../code/event-writer/handler.py | 28 ++++ examples/demo-managed-functions/function.tf | 77 +++++++++++ examples/demo-managed-functions/main.tf | 18 +++ .../terraform.tfvars.example | 11 ++ examples/demo-managed-functions/variables.tf | 38 ++++++ main.go | 4 +- 14 files changed, 687 insertions(+), 1 deletion(-) create mode 100644 deployments/k8s/nodered.yaml create mode 100644 deployments/k8s/operator-demo.yaml create mode 100644 deployments/k8s/rabbitmq.yaml create mode 100644 examples/demo-event-log/event-cleaner/code/handler.py create mode 100644 examples/demo-event-log/event-monitor/code/handler.py create mode 100644 examples/demo-event-log/event-writer/code/handler.py create mode 100644 examples/demo-managed-functions/code/event-cleaner/handler.py create mode 100644 examples/demo-managed-functions/code/event-monitor/handler.py create mode 100644 examples/demo-managed-functions/code/event-writer/handler.py create mode 100644 examples/demo-managed-functions/function.tf create mode 100644 examples/demo-managed-functions/main.tf create mode 100644 examples/demo-managed-functions/terraform.tfvars.example create mode 100644 examples/demo-managed-functions/variables.tf diff --git a/deployments/k8s/nodered.yaml b/deployments/k8s/nodered.yaml new file mode 100644 index 0000000..17c57a1 --- /dev/null +++ b/deployments/k8s/nodered.yaml @@ -0,0 +1,122 @@ +# Изменено: 2026-03-14 +# Node-RED для визуального управления demo сценарием Event Log. +# Образ: nodered/node-red:4 (официальный, всегда есть на DockerHub) +# UI доступен по http:///nodered/ +# Для AMQP нужен плагин node-red-contrib-amqp — ставится через initContainer. +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: nodered-data + namespace: sless +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nodered + namespace: sless + labels: + app: nodered +spec: + replicas: 1 + selector: + matchLabels: + app: nodered + template: + metadata: + labels: + app: nodered + spec: + securityContext: + fsGroup: 1000 + # initContainer устанавливает AMQP-плагин в PVC до старта основного контейнера + initContainers: + - name: install-nodes + image: nodered/node-red:latest + securityContext: + runAsUser: 0 + runAsGroup: 0 + command: + - sh + - -c + - | + chown -R 1000:1000 /data || true + cd /data + npm install --prefix /data node-red-contrib-amqp node-red-dashboard 2>&1 || true + volumeMounts: + - name: data + mountPath: /data + containers: + - name: nodered + image: nodered/node-red:latest + ports: + - containerPort: 1880 + env: + - name: NODE_RED_ENABLE_PROJECTS + value: "false" + - name: TZ + value: "Europe/Moscow" + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + readinessProbe: + httpGet: + path: / + port: 1880 + initialDelaySeconds: 15 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: nodered-data +--- +apiVersion: v1 +kind: Service +metadata: + name: nodered + namespace: sless +spec: + selector: + app: nodered + ports: + - port: 1880 + targetPort: 1880 +--- +# Ingress на Node-RED — доступен снаружи по http://nodered.185.247.187.147.nip.io +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: nodered + namespace: sless + annotations: + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" +spec: + ingressClassName: nginx + rules: + - host: nodered.185.247.187.147.nip.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: nodered + port: + number: 1880 diff --git a/deployments/k8s/operator-demo.yaml b/deployments/k8s/operator-demo.yaml new file mode 100644 index 0000000..868cd21 --- /dev/null +++ b/deployments/k8s/operator-demo.yaml @@ -0,0 +1,125 @@ +# Изменено: 2026-03-14 +# Деплой sless оператора на demo-стенде (naeel-test-3, nip.io, без TLS). +# Отличия от production operator.yaml: +# - REGISTRY_HOST=naeel (DockerHub, не Harbor) +# - INGRESS_HOST и EXTERNAL_URL — через nip.io без TLS +# - cert-manager аннотации убраны +# - API_TOKEN берётся из sless-operator-secret (совпадает с secrets/test.token) +# +# Перед apply нужно создать секреты: +# kubectl create secret generic sless-operator-secret -n sless \ +# --from-literal=POSTGRES_DSN="postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" \ +# --from-literal=S3_ACCESS_KEY="0GLQRD38H4I6RBDB0EWJ" \ +# --from-literal=S3_SECRET_KEY="eTFibiHmBd96IApj9PYsboTR6OBoD7osxoarHykw" \ +# --from-literal=SLESS_API_TOKEN="" \ +# --from-literal=HARBOR_PASS="" +# +# kubectl create secret docker-registry sless-registry-auth -n sless \ +# --docker-server=https://index.docker.io/v1/ \ +# --docker-username=naeel \ +# --docker-password= +--- +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" + # Harbor как registry для demo + REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru" + REGISTRY_SECRET: "sless-registry-auth" + HARBOR_USER: "admin" + API_PORT: "9090" + # nip.io домен без TLS — работает без настройки DNS + INGRESS_HOST: "fn.185.247.187.147.nip.io" + EXTERNAL_URL: "http://sless-api.185.247.187.147.nip.io" +--- +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 + image: naeel/sless-operator:v0.1.29 + 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 +--- +# Ingress без TLS — demo стенд через nip.io +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: sless-operator + namespace: sless +spec: + ingressClassName: nginx + rules: + - host: sless-api.185.247.187.147.nip.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: sless-operator + port: + number: 9090 diff --git a/deployments/k8s/rabbitmq.yaml b/deployments/k8s/rabbitmq.yaml new file mode 100644 index 0000000..2cceb64 --- /dev/null +++ b/deployments/k8s/rabbitmq.yaml @@ -0,0 +1,73 @@ +# Изменено: 2026-03-14 +# RabbitMQ для demo сценария Event Log. +# Используется официальный образ с management-плагином для веб-UI. +# credentials: sless / sless123 +# AMQP: amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/ +# Management UI: http://rabbitmq.sless.svc.cluster.local:15672/ +--- +apiVersion: v1 +kind: Secret +metadata: + name: rabbitmq-secret + namespace: sless +stringData: + RABBITMQ_DEFAULT_USER: "sless" + RABBITMQ_DEFAULT_PASS: "sless123" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rabbitmq + namespace: sless + labels: + app: rabbitmq +spec: + replicas: 1 + selector: + matchLabels: + app: rabbitmq + template: + metadata: + labels: + app: rabbitmq + spec: + containers: + - name: rabbitmq + image: rabbitmq:3.13-management-alpine + ports: + - name: amqp + containerPort: 5672 + - name: management + containerPort: 15672 + envFrom: + - secretRef: + name: rabbitmq-secret + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "500m" + readinessProbe: + tcpSocket: + port: 5672 + initialDelaySeconds: 40 + periodSeconds: 10 + timeoutSeconds: 10 +--- +apiVersion: v1 +kind: Service +metadata: + name: rabbitmq + namespace: sless +spec: + selector: + app: rabbitmq + ports: + - name: amqp + port: 5672 + targetPort: 5672 + - name: management + port: 15672 + targetPort: 15672 diff --git a/examples/demo-event-log/event-cleaner/code/handler.py b/examples/demo-event-log/event-cleaner/code/handler.py new file mode 100644 index 0000000..0fcb629 --- /dev/null +++ b/examples/demo-event-log/event-cleaner/code/handler.py @@ -0,0 +1,45 @@ +# Изменено: 2026-03-14 +# Функция event-cleaner: удаляет N самых старых строк из таблицы events. +# Вызывается через HTTP POST из Node-RED (который слушает RabbitMQ). +# Env: POSTGRES_DSN — строка подключения к PostgreSQL. + +import os +import json +import psycopg2 + +def handle(request): + """Удаляет N старейших строк из таблицы events.""" + dsn = os.environ["POSTGRES_DSN"] + + body = {} + if request.get_data(): + try: + body = json.loads(request.get_data()) + except Exception: + pass + + # Количество строк для удаления — из тела запроса или дефолт 10 + delete_n = int(body.get("delete_n", 10)) + # Защита от случайного удаления слишком большого количества строк + delete_n = min(delete_n, 100) + + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + cur.execute(""" + DELETE FROM events + WHERE id IN ( + SELECT id FROM events ORDER BY created_at ASC LIMIT %s + ) + """, (delete_n,)) + deleted = cur.rowcount + cur.execute("SELECT COUNT(*) FROM events") + remaining = cur.fetchone()[0] + conn.commit() + return json.dumps({ + "ok": True, + "deleted": deleted, + "remaining": remaining + }), 200, {"Content-Type": "application/json"} + finally: + conn.close() diff --git a/examples/demo-event-log/event-monitor/code/handler.py b/examples/demo-event-log/event-monitor/code/handler.py new file mode 100644 index 0000000..d95dbfe --- /dev/null +++ b/examples/demo-event-log/event-monitor/code/handler.py @@ -0,0 +1,47 @@ +# Изменено: 2026-03-14 +# Функция event-monitor: считает строки в events. +# Если больше 50 — публикует сообщение в RabbitMQ queue "cleanup-needed". +# Запускается по cron (каждую минуту). +# Env: +# POSTGRES_DSN — строка подключения к PostgreSQL +# RABBITMQ_URL — amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/ + +import os +import json +import psycopg2 +import pika + +THRESHOLD = 50 + +def handle(request): + """Мониторит таблицу events. При переполнении шлёт в RabbitMQ.""" + dsn = os.environ["POSTGRES_DSN"] + rabbit_url = os.environ["RABBITMQ_URL"] + + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + # Считаем количество событий + cur.execute("SELECT COUNT(*) FROM events") + count = cur.fetchone()[0] + finally: + conn.close() + + result = {"count": count, "threshold": THRESHOLD, "action": "none"} + + if count > THRESHOLD: + # Публикуем в очередь — event-cleaner получит и удалит старые строки + params = pika.URLParameters(rabbit_url) + connection = pika.BlockingConnection(params) + channel = connection.channel() + channel.queue_declare(queue="cleanup-needed", durable=True) + channel.basic_publish( + exchange="", + routing_key="cleanup-needed", + body=json.dumps({"count": count, "delete_n": 10}), + properties=pika.BasicProperties(delivery_mode=2) # persistent + ) + connection.close() + result["action"] = "cleanup_requested" + + return json.dumps(result), 200, {"Content-Type": "application/json"} diff --git a/examples/demo-event-log/event-writer/code/handler.py b/examples/demo-event-log/event-writer/code/handler.py new file mode 100644 index 0000000..e4fcae8 --- /dev/null +++ b/examples/demo-event-log/event-writer/code/handler.py @@ -0,0 +1,49 @@ +# Изменено: 2026-03-14 +# Функция event-writer: принимает HTTP POST, пишет одну строку в таблицу events. +# Таблица создаётся автоматически при первом запуске. +# Env: POSTGRES_DSN — строка подключения к PostgreSQL. + +import os +import json +import psycopg2 +from datetime import datetime, timezone + +def handle(request): + """Записывает одно событие в таблицу events.""" + dsn = os.environ["POSTGRES_DSN"] + + body = {} + if request.get_data(): + try: + body = json.loads(request.get_data()) + except Exception: + pass + + source = body.get("source", "node-red") + message = body.get("message", "ping") + + conn = psycopg2.connect(dsn) + try: + with conn.cursor() as cur: + # Создаём таблицу если нет — безопасно вызывать при каждом запросе + cur.execute(""" + CREATE TABLE IF NOT EXISTS events ( + id SERIAL PRIMARY KEY, + source VARCHAR(100) NOT NULL DEFAULT 'unknown', + message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """) + cur.execute( + "INSERT INTO events (source, message) VALUES (%s, %s) RETURNING id, created_at", + (source, message) + ) + row = cur.fetchone() + conn.commit() + return json.dumps({ + "ok": True, + "id": row[0], + "created_at": row[1].isoformat() + }), 200, {"Content-Type": "application/json"} + finally: + conn.close() diff --git a/examples/demo-managed-functions/code/event-cleaner/handler.py b/examples/demo-managed-functions/code/event-cleaner/handler.py new file mode 100644 index 0000000..5eec764 --- /dev/null +++ b/examples/demo-managed-functions/code/event-cleaner/handler.py @@ -0,0 +1,26 @@ +# Изменено: 2026-03-14 +# event-cleaner: HTTP функция для демонстрации контролируемого изменения логики. +# Работает без внешних пакетов. + +import json +from datetime import datetime, timezone + + +def handle(event): + """Принимает delete_n и возвращает подтверждение обработки.""" + + payload = event if isinstance(event, dict) else {} + + delete_n = int(payload.get("delete_n", 10)) + delete_n = max(1, min(delete_n, 100)) + + now = datetime.now(timezone.utc).isoformat() + # Здесь intentionally имитируем очистку, чтобы показать реакцию на входные параметры. + return json.dumps( + { + "ok": True, + "accepted_delete_n": delete_n, + "status": "simulated-cleanup", + "generated_at": now, + } + ), 200, {"Content-Type": "application/json"} diff --git a/examples/demo-managed-functions/code/event-monitor/handler.py b/examples/demo-managed-functions/code/event-monitor/handler.py new file mode 100644 index 0000000..e741ea1 --- /dev/null +++ b/examples/demo-managed-functions/code/event-monitor/handler.py @@ -0,0 +1,25 @@ +# Изменено: 2026-03-14 +# event-monitor: cron-функция для демонстрации расписания и управления кодом. +# Работает без внешних библиотек и возвращает диагностический JSON. + +import json +import os +from datetime import datetime, timezone + + +THRESHOLD = 50 + + +def handle(request): + """Отдаёт heartbeat для cron-запуска и видимой проверки после apply.""" + rabbitmq_url = os.environ.get("RABBITMQ_URL", "not-set") + now = datetime.now(timezone.utc).isoformat() + return json.dumps( + { + "ok": True, + "monitor": "alive", + "threshold": THRESHOLD, + "rabbitmq_configured": rabbitmq_url != "not-set", + "generated_at": now, + } + ), 200, {"Content-Type": "application/json"} diff --git a/examples/demo-managed-functions/code/event-writer/handler.py b/examples/demo-managed-functions/code/event-writer/handler.py new file mode 100644 index 0000000..5035259 --- /dev/null +++ b/examples/demo-managed-functions/code/event-writer/handler.py @@ -0,0 +1,28 @@ +# Изменено: 2026-03-14 +# event-writer: простая HTTP функция без внешних зависимостей. +# Правка поля response_tag в коде сразу видна в ответе после terraform apply. + +import json +import os +from datetime import datetime, timezone + + +def handle(event): + """Возвращает полезный JSON-ответ для визуальной проверки выката кода.""" + default_message = os.environ.get("DEFAULT_MESSAGE", "writer-default") + + payload = event if isinstance(event, dict) else {} + + message = payload.get("message", default_message) + source = payload.get("source", "event-writer") + now = datetime.now(timezone.utc).isoformat() + + # response_tag удобно менять для демонстрации hot-update кода через terraform apply. + response_tag = "writer-v2" + return { + "ok": True, + "source": source, + "message": message, + "response_tag": response_tag, + "generated_at": now, + } diff --git a/examples/demo-managed-functions/function.tf b/examples/demo-managed-functions/function.tf new file mode 100644 index 0000000..0e1c1ef --- /dev/null +++ b/examples/demo-managed-functions/function.tf @@ -0,0 +1,77 @@ +# 2026-03-14 +# function.tf — ресурсы managed serverless функций для демонстрации. +# Пользователь правит код в code/* и запускает terraform apply — провайдер сам пересобирает и выкатывает функции. + +resource "sless_function" "event_writer" { + name = "event-writer" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 20 + + source_dir = "${path.module}/code/event-writer" + + env_vars = { + POSTGRES_DSN = var.pg_dsn + DEFAULT_MESSAGE = var.writer_message + } +} + +resource "sless_trigger" "event_writer_http" { + name = "event-writer-http" + type = "http" + function = sless_function.event_writer.name + enabled = true +} + +resource "sless_function" "event_monitor" { + name = "event-monitor" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 20 + + source_dir = "${path.module}/code/event-monitor" + + env_vars = { + POSTGRES_DSN = var.pg_dsn + RABBITMQ_URL = var.rabbitmq_url + } +} + +resource "sless_trigger" "event_monitor_cron" { + name = "event-monitor-cron" + type = "cron" + function = sless_function.event_monitor.name + enabled = true + schedule = "*/1 * * * *" +} + +resource "sless_function" "event_cleaner" { + name = "event-cleaner" + runtime = "python3.11" + entrypoint = "handler.handle" + memory_mb = 128 + timeout_sec = 20 + + source_dir = "${path.module}/code/event-cleaner" + + env_vars = { + POSTGRES_DSN = var.pg_dsn + } +} + +resource "sless_trigger" "event_cleaner_http" { + name = "event-cleaner-http" + type = "http" + function = sless_function.event_cleaner.name + enabled = true +} + +output "event_writer_url" { + value = sless_trigger.event_writer_http.url +} + +output "event_cleaner_url" { + value = sless_trigger.event_cleaner_http.url +} diff --git a/examples/demo-managed-functions/main.tf b/examples/demo-managed-functions/main.tf new file mode 100644 index 0000000..10fd047 --- /dev/null +++ b/examples/demo-managed-functions/main.tf @@ -0,0 +1,18 @@ +# 2026-03-14 +# Terraform demo: managed serverless functions. +# Здесь управляем ТОЛЬКО функциями/триггерами, внешние сервисы считаем уже поднятыми. + +terraform { + required_providers { + sless = { + source = "terra.k8c.ru/naeel/sless" + version = "~> 0.1.18" + } + } +} + +provider "sless" { + endpoint = var.sless_endpoint + token = var.token + nubes_endpoint = var.nubes_endpoint +} diff --git a/examples/demo-managed-functions/terraform.tfvars.example b/examples/demo-managed-functions/terraform.tfvars.example new file mode 100644 index 0000000..983f6da --- /dev/null +++ b/examples/demo-managed-functions/terraform.tfvars.example @@ -0,0 +1,11 @@ +# 2026-03-14 +# Скопируй в terraform.tfvars и подставь актуальный токен. + +token = "PUT_TOKEN_HERE" +sless_endpoint = "http://sless-api.185.247.187.147.nip.io" +nubes_endpoint = "https://deck-test.ngcloud.ru/api/v1" + +pg_dsn = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" +rabbitmq_url = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/" + +writer_message = "writer-default-v1" diff --git a/examples/demo-managed-functions/variables.tf b/examples/demo-managed-functions/variables.tf new file mode 100644 index 0000000..3ba52ed --- /dev/null +++ b/examples/demo-managed-functions/variables.tf @@ -0,0 +1,38 @@ +# 2026-03-14 +# Переменные для demo-managed-functions. + +variable "token" { + description = "JWT токен API" + type = string + sensitive = true +} + +variable "sless_endpoint" { + description = "Endpoint sless API" + type = string + default = "http://sless-api.185.247.187.147.nip.io" +} + +variable "nubes_endpoint" { + description = "Nubes endpoint (нужен провайдеру)" + type = string + default = "https://deck-test.ngcloud.ru/api/v1" +} + +variable "pg_dsn" { + description = "PostgreSQL DSN для функций" + type = string + default = "postgres://sless:sless-pg-password@postgres.sless.svc.cluster.local:5432/sless?sslmode=disable" +} + +variable "rabbitmq_url" { + description = "RabbitMQ URL для функций" + type = string + default = "amqp://sless:sless123@rabbitmq.sless.svc.cluster.local:5672/" +} + +variable "writer_message" { + description = "Сообщение по умолчанию, которое пишет event-writer" + type = string + default = "writer-default-v1" +} diff --git a/main.go b/main.go index 37564eb..555989c 100644 --- a/main.go +++ b/main.go @@ -121,8 +121,10 @@ func main() { // Builder использует k8s client из manager'а // harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается). var harborClient *harbor.Client + var harborProjecter builder.Projecter if cfg.HarborUser != "" && cfg.HarborPass != "" { harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass) + harborProjecter = harborClient log.Info("harbor client configured", "host", cfg.RegistryHost) } else { log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)") @@ -137,7 +139,7 @@ func main() { S3SecretKey: cfg.S3SecretKey, S3Bucket: cfg.S3Bucket, Namespace: "sless", - HarborClient: harborClient, + HarborClient: harborProjecter, }) if err = (&controllers.FunctionReconciler{