feat: harbor integration — EnsureProject + per-namespace image path

- internal/harbor/client.go: новый пакет, EnsureProject (GET+POST idempotent)
- config.go: добавлены HarborUser/HarborPass (из HARBOR_USER/HARBOR_PASS env)
- builder.go: Projecter интерфейс, harborClient поле, ImageRef: {host}/{ns}/{func}:{tag}
  EnsureProject вызывается перед каждым Build()
- function_controller.go: HarborClient поле, EnsureProject при создании k8s NS
- main.go: создание harbor.Client если HARBOR_USER+HARBOR_PASS заданы
- operator.yaml: REGISTRY_HOST=pearlharbor..., HARBOR_USER=admin, v0.1.24
- hack/create-registry-secret.sh: переписан для Harbor (HARBOR_USER/HARBOR_PASS)

Смена registry — только через REGISTRY_HOST в ConfigMap, больше нигде.
This commit is contained in:
“Naeel”
2026-03-11 14:58:04 +04:00
parent 6443f21ddc
commit babd8e6109
7 changed files with 185 additions and 34 deletions
+10 -1
View File
@@ -1,4 +1,4 @@
// Изменено: 2026-03-08
// Изменено: 2026-03-11
// FunctionReconciler — основной контроллер оператора.
// Следит за CRD Function и управляет lifecycle функции:
// Pending → Building (запуск kaniko Job) → Ready (образ собран, Deployment создан) / Failed
@@ -25,6 +25,7 @@ import (
slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
)
// FunctionReconciler reconciles a Function object
@@ -34,6 +35,7 @@ type FunctionReconciler struct {
Builder *builder.Builder
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
HarborClient *harbor.Client // nil — Harbor не используется, EnsureProject пропускается
}
//+kubebuilder:rbac:groups=sless.kube5s.ru,resources=functions,verbs=get;list;watch;create;update;patch;delete
@@ -183,6 +185,13 @@ func (r *FunctionReconciler) ensureDeployment(ctx context.Context, fn *slessv1al
if err := r.Create(ctx, ns); err != nil {
return ctrl.Result{}, fmt.Errorf("create function namespace: %w", err)
}
// Создаём Harbor-проект для namespace сразу при создании k8s NS (best-effort).
// Если не удалось — EnsureProject повторит вызов внутри Build().
if r.HarborClient != nil {
if err := r.HarborClient.EnsureProject(ctx, fn.Namespace); err != nil {
log.FromContext(ctx).Error(err, "harbor ensure project on ns create", "project", fn.Namespace)
}
}
} else {
return ctrl.Result{}, fmt.Errorf("get function namespace: %w", err)
}
+15 -13
View File
@@ -1,20 +1,14 @@
# Изменено: 2026-03-07
# Изменено: 2026-03-11
# Деплой sless оператора в кластер.
# Состав:
# - ConfigMap: не-секретные env vars (S3_ENDPOINT, REGISTRY_HOST и т.д.)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, docker auth)
# - Secret: секретные данные (S3 keys, postgres DSN, API token, Harbor pass)
# - Deployment: оператор naeel/sless-operator:v0.1.23 в 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 -
# Чтобы сменить registry — менять только REGISTRY_HOST в ConfigMap.
# Чтобы сменить Harbor-аккаунт — менять HARBOR_USER в ConfigMap + HARBOR_PASS в Secret.
#
# Применение: kubectl apply -f deployments/k8s/operator.yaml
---
@@ -27,8 +21,13 @@ data:
S3_ENDPOINT: "s3.msk-1.ngcloud.ru"
S3_BUCKET: "sless-functions"
S3_USE_SSL: "true"
REGISTRY_HOST: "naeel"
# REGISTRY_HOST — единственное место, где прописан адрес registry.
# Чтобы сменить реестр — менять только здесь.
# Harbor: pearlharbor.registryk8s.services.ngcloud.ru
# DockerHub (legacy): naeel
REGISTRY_HOST: "pearlharbor.registryk8s.services.ngcloud.ru"
REGISTRY_SECRET: "sless-registry-auth"
HARBOR_USER: "admin"
API_PORT: "9090"
INGRESS_HOST: "fn.kube5s.ru"
# EXTERNAL_URL — если задан, URL функции = EXTERNAL_URL/fn/{namespace}/{name}
@@ -41,13 +40,16 @@ data:
# S3_ACCESS_KEY — ключ доступа к S3/Ceph
# S3_SECRET_KEY — секретный ключ S3/Ceph
# SLESS_API_TOKEN — токен аутентификации API
# HARBOR_PASS — пароль Harbor API (для EnsureProject, не для kaniko push)
#
# Пример создания:
# 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"
# --from-literal=SLESS_API_TOKEN="your-token-here" \
# --from-literal=HARBOR_PASS="harbor-admin-password"
# kubectl apply -f hack/create-registry-secret.sh # docker-креды для kaniko
---
apiVersion: apps/v1
kind: Deployment
@@ -70,7 +72,7 @@ spec:
containers:
- name: operator
# При обновлении версии оператора — менять тег здесь (не latest!)
image: naeel/sless-operator:v0.1.23
image: naeel/sless-operator:v0.1.24
# Always — чтобы всегда тянуть по точному тегу (не кешировать старый)
imagePullPolicy: Always
ports:
+15 -11
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env bash
# Изменено: 2026-03-07
# Создаёт k8s Secret sless-registry-auth с DockerHub кредами для kaniko.
# Изменено: 2026-03-11
# Создаёт k8s Secret sless-registry-auth с кредами Harbor для kaniko.
# Kaniko монтирует этот secret как /kaniko/.docker/config.json для push образов.
# Отдельно от HARBOR_PASS в operator-secret: этот secret — для kaniko (push),
# HARBOR_PASS — для Harbor API (создание проектов через EnsureProject).
#
# Использование:
# DOCKER_HUB_USER=naeel DOCKER_HUB_TOKEN=<token> ./hack/create-registry-secret.sh
# HARBOR_USER=admin HARBOR_PASS=<password> REGISTRY=pearlharbor.registryk8s.services.ngcloud.ru \
# ./hack/create-registry-secret.sh
#
# Требуется: kubectl, авторизованный context с доступом к namespace sless.
@@ -12,21 +15,22 @@ set -euo pipefail
NAMESPACE="sless"
SECRET_NAME="sless-registry-auth"
REGISTRY="${REGISTRY:-pearlharbor.registryk8s.services.ngcloud.ru}"
if [[ -z "${DOCKER_HUB_USER:-}" ]]; then
echo "ERROR: DOCKER_HUB_USER env var is required"
if [[ -z "${HARBOR_USER:-}" ]]; then
echo "ERROR: HARBOR_USER env var is required"
exit 1
fi
if [[ -z "${DOCKER_HUB_TOKEN:-}" ]]; then
echo "ERROR: DOCKER_HUB_TOKEN env var is required (DockerHub access token, not password)"
if [[ -z "${HARBOR_PASS:-}" ]]; then
echo "ERROR: HARBOR_PASS env var is required"
exit 1
fi
kubectl create secret docker-registry "${SECRET_NAME}" \
--docker-username="${DOCKER_HUB_USER}" \
--docker-password="${DOCKER_HUB_TOKEN}" \
--docker-server="https://index.docker.io/v1/" \
--docker-username="${HARBOR_USER}" \
--docker-password="${HARBOR_PASS}" \
--docker-server="${REGISTRY}" \
--namespace="${NAMESPACE}" \
--dry-run=client -o yaml | kubectl apply -f -
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}'"
echo "OK: secret '${SECRET_NAME}' applied in namespace '${NAMESPACE}' (server: ${REGISTRY})"
+25 -4
View File
@@ -1,9 +1,9 @@
// Изменено: 2026-03-07
// Изменено: 2026-03-11
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
// Почему kaniko, а не docker-in-docker (DinD):
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
// kaniko читает контекст сборки из S3 напрямую.
// Workflow: FunctionController вызывает Build → Job запускается → образ пушится в registry.
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
package builder
@@ -20,17 +20,24 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
)
// Projecter — опциональный интерфейс для реестров с поддержкой проектов (Harbor).
// Если nil — вызов EnsureProject пропускается (Docker Hub, другие реестры без проектов).
type Projecter interface {
EnsureProject(ctx context.Context, name string) error
}
// Builder — управляет сборкой Docker образов через kaniko Jobs в k8s.
type Builder struct {
client client.Client
builderImage string // образ kaniko
registryHost string // куда пушим образ
registryHost string // куда пушим образ (Harbor: host; DockerHub: user/org)
registrySecret string // имя k8s Secret с docker-кредами для kaniko
s3Endpoint string // откуда kaniko берёт код
s3AccessKey string
s3SecretKey string
s3Bucket string
namespace string // namespace где запускаем build Job'ы
harborClient Projecter // nil если Harbor не используется
}
// Config — параметры для создания Builder'а.
@@ -43,6 +50,7 @@ type Config struct {
S3SecretKey string
S3Bucket string
Namespace string
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
}
// New создаёт новый Builder.
@@ -57,21 +65,34 @@ func New(c client.Client, cfg Config) *Builder {
s3SecretKey: cfg.S3SecretKey,
s3Bucket: cfg.S3Bucket,
namespace: cfg.Namespace,
harborClient: cfg.HarborClient,
}
}
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
// Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при
// повторном reconcile с тем же s3Key, не требует хаков с :latest.
// Формат: {registryHost}/{namespace}/{funcName}:{tag}
//
// Harbor: pearlharbor.registryk8s.services.ngcloud.ru/{ns}/{func}:{tag}
//
// Чтобы сменить реестр — достаточно изменить REGISTRY_HOST в одном месте (ConfigMap).
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
h := sha256.Sum256([]byte(s3Key))
tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов
return fmt.Sprintf("%s/sless-%s-%s:%s", b.registryHost, namespace, funcName, tag)
return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, namespace, funcName, tag)
}
// Build запускает kaniko Job для сборки образа функции.
// Возвращает имя Job'а чтобы контроллер мог следить за его статусом.
func (b *Builder) Build(ctx context.Context, namespace, funcName, s3Key string) (string, error) {
// Идемпотентно создаём Harbor-проект перед push — kaniko сам его не создаёт.
// Пропускаем если HarborClient не задан (Docker Hub и другие реестры без проектов).
if b.harborClient != nil {
if err := b.harborClient.EnsureProject(ctx, namespace); err != nil {
return "", fmt.Errorf("harbor ensure project %q: %w", namespace, err)
}
}
imageRef := b.ImageRef(namespace, funcName, s3Key)
jobName := fmt.Sprintf("build-%s-%s", funcName, time.Now().Format("20060102150405"))
+11 -2
View File
@@ -50,10 +50,14 @@ type Config struct {
// Поле сохранено для совместимости конфигурации.
// Аутентификация происходит в middleware/auth.go через validateJWT().
APIToken string
// Harbor — креды для Harbor API (создание проектов до push образов).
// REGISTRY_HOST уже содержит адрес реестра — дублировать не нужно.
// Если не заданы, EnsureProject пропускается (только DockerHub/без проектов).
HarborUser string
HarborPass string
}
// Load читает конфиг из env переменных.
// Возвращает ошибку если обязательные переменные не заданы.
func Load() (*Config, error) {
cfg := &Config{
APIPort: 8080,
@@ -128,5 +132,10 @@ func Load() (*Config, error) {
// Не required: auth middleware использует validateJWT, а не статический токен.
cfg.APIToken = os.Getenv("SLESS_API_TOKEN")
// Harbor API креды — опциональны.
// Если не заданы, EnsureProject пропускается (push работает через docker-кред в kaniko).
cfg.HarborUser = os.Getenv("HARBOR_USER")
cfg.HarborPass = os.Getenv("HARBOR_PASS")
return cfg, nil
}
+94
View File
@@ -0,0 +1,94 @@
// Изменено: 2026-03-11
// Harbor API клиент — управление проектами (create/ensure) перед push образов.
// Почему здесь: kaniko не создаёт Harbor-проект сам, нужен явный вызов API.
// Все операции идемпотентны - повторный вызов EnsureProject безопасен.
package harbor
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// Client — минимальный HTTP-клиент для Harbor API v2.
// Используется только для создания проектов; пуш образов делает kaniko напрямую.
type Client struct {
baseURL string // https://{registryHost}
user string
password string
http *http.Client
}
// New создаёт Harbor клиент.
// registryHost — хост реестра без схемы (e.g. "pearlharbor.registryk8s.services.ngcloud.ru").
func New(registryHost, user, password string) *Client {
return &Client{
baseURL: "https://" + registryHost,
user: user,
password: password,
http: &http.Client{Timeout: 15 * time.Second},
}
}
// EnsureProject идемпотентно создаёт Harbor проект с именем name (private).
// GET /api/v2.0/projects?name={name} → если не найден → POST /api/v2.0/projects.
// Возвращает nil если проект уже существовал или был успешно создан.
func (c *Client) EnsureProject(ctx context.Context, name string) error {
// Harbor GET projects возвращает список проектов по фильтру name=
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("%s/api/v2.0/projects?name=%s", c.baseURL, name), nil)
if err != nil {
return fmt.Errorf("harbor: build GET request: %w", err)
}
req.SetBasicAuth(c.user, c.password)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("harbor: GET project %q: %w", name, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("harbor: unauthorized — проверьте HARBOR_USER и HARBOR_PASS")
}
var projects []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
return fmt.Errorf("harbor: decode GET response: %w", err)
}
for _, p := range projects {
if p.Name == name {
return nil // проект уже существует
}
}
// Проект не найден — создаём
body, _ := json.Marshal(map[string]interface{}{
"project_name": name,
"public": false,
})
req, err = http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/v2.0/projects", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("harbor: build POST request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.SetBasicAuth(c.user, c.password)
resp2, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("harbor: POST project %q: %w", name, err)
}
defer resp2.Body.Close()
// 201 Created — успешно; 409 Conflict — гонка (кто-то создал параллельно), оба ок
if resp2.StatusCode != http.StatusCreated && resp2.StatusCode != http.StatusConflict {
return fmt.Errorf("harbor: POST project %q: unexpected status %d", name, resp2.StatusCode)
}
return nil
}
+12
View File
@@ -31,6 +31,7 @@ import (
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/api/handler"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/builder"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/config"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/harbor"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/postgres"
"gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/internal/storage/s3"
//+kubebuilder:scaffold:imports
@@ -118,6 +119,15 @@ func main() {
}
// Builder использует k8s client из manager'а
// harborClient — nil если HARBOR_USER/HARBOR_PASS не заданы (EnsureProject пропускается).
var harborClient *harbor.Client
if cfg.HarborUser != "" && cfg.HarborPass != "" {
harborClient = harbor.New(cfg.RegistryHost, cfg.HarborUser, cfg.HarborPass)
log.Info("harbor client configured", "host", cfg.RegistryHost)
} else {
log.Info("harbor client disabled (HARBOR_USER/HARBOR_PASS not set)")
}
bldr := builder.New(mgr.GetClient(), builder.Config{
BuilderImage: cfg.BuilderImage,
RegistryHost: cfg.RegistryHost,
@@ -127,6 +137,7 @@ func main() {
S3SecretKey: cfg.S3SecretKey,
S3Bucket: cfg.S3Bucket,
Namespace: "sless",
HarborClient: harborClient,
})
if err = (&controllers.FunctionReconciler{
@@ -135,6 +146,7 @@ func main() {
Builder: bldr,
RegistrySecret: cfg.RegistrySecret,
OperatorNamespace: "sless",
HarborClient: harborClient,
}).SetupWithManager(mgr); err != nil {
log.Error("unable to create controller", "controller", "Function", "err", err)
os.Exit(1)