diff --git a/controllers/function_controller.go b/controllers/function_controller.go index 4790003..aa8160a 100644 --- a/controllers/function_controller.go +++ b/controllers/function_controller.go @@ -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) } diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 57c464c..0f02fad 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -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: diff --git a/hack/create-registry-secret.sh b/hack/create-registry-secret.sh index 22af144..585fe7c 100755 --- a/hack/create-registry-secret.sh +++ b/hack/create-registry-secret.sh @@ -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= ./hack/create-registry-secret.sh +# HARBOR_USER=admin HARBOR_PASS= 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})" diff --git a/internal/builder/builder.go b/internal/builder/builder.go index bc52773..e7ea4ef 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -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")) diff --git a/internal/config/config.go b/internal/config/config.go index 44bbab8..6c7855c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/harbor/client.go b/internal/harbor/client.go new file mode 100644 index 0000000..13231c9 --- /dev/null +++ b/internal/harbor/client.go @@ -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 +} diff --git a/main.go b/main.go index 150bdfb..bd20ffd 100644 --- a/main.go +++ b/main.go @@ -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)