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:
@@ -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
|
||||
@@ -32,8 +33,9 @@ type FunctionReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
Builder *builder.Builder
|
||||
RegistrySecret string // имя Secret с docker credentials (для imagePullSecrets в подах функций)
|
||||
OperatorNamespace string // namespace оператора — откуда копируем RegistrySecret в sless-fn-*
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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})"
|
||||
|
||||
@@ -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'ы
|
||||
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"))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user