Files
sless/internal/builder/builder.go
T

333 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Изменено: 2026-03-22
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
// Почему kaniko, а не docker-in-docker (DinD):
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
// kaniko читает контекст сборки из S3 напрямую.
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
// ImageExists: перед сборкой проверяем наличие образа по тегу в registry.
// Если образ с таким тегом (= hash кода) уже существует — сборка пропускается.
// Это даёт идемпотентность destroy+apply: если код не менялся, deploy мгновенный.
package builder
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"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 // куда пушим образ (DockerHub: "naeel"; Harbor: "host")
registryProject string // проект/org внутри registry (Harbor: project; DockerHub: пусто)
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'а.
type Config struct {
BuilderImage string
RegistryHost string
RegistryProject string // пусто = DockerHub-режим (2 уровня); задан = project-режим (3 уровня)
RegistrySecret string // имя k8s Secret с .dockerconfigjson для пуша образов
S3Endpoint string
S3AccessKey string
S3SecretKey string
S3Bucket string
Namespace string
HarborClient Projecter // nil — Harbor не используется, EnsureProject пропускается
}
// New создаёт новый Builder.
func New(c client.Client, cfg Config) *Builder {
return &Builder{
client: c,
builderImage: cfg.BuilderImage,
registryHost: cfg.RegistryHost,
registryProject: cfg.RegistryProject,
registrySecret: cfg.RegistrySecret,
s3Endpoint: cfg.S3Endpoint,
s3AccessKey: cfg.S3AccessKey,
s3SecretKey: cfg.S3SecretKey,
s3Bucket: cfg.S3Bucket,
namespace: cfg.Namespace,
harborClient: cfg.HarborClient,
}
}
// ImageRef возвращает полный путь к образу в registry для данной функции и версии.
// Тег = первые 12 символов sha256(s3Key): уникален per build, не меняется при
// повторном reconcile с тем же s3Key, не требует хаков с :latest.
//
// Два режима — переключаются через REGISTRY_PROJECT env без пересборки оператора:
//
// project-режим (REGISTRY_PROJECT задан):
// {host}/{project}/{func}:{tag}
// Для: Harbor, GCR, GHCR, любой registry с project-изоляцией
// Пример: pearlharbor.host/myproject/chaos-echo:abc123
//
// flat-режим (REGISTRY_PROJECT пуст):
// {host}/{nsPrefix}-{func}:{tag}
// Для: DockerHub (max 2 уровня: user/repo)
// nsPrefix = первые 8 символов namespace — tenant-изоляция в имени репо
// Пример: naeel/slessffd1-chaos-echo:abc123
func (b *Builder) ImageRef(namespace, funcName, s3Key string) string {
h := sha256.Sum256([]byte(s3Key))
tag := fmt.Sprintf("%x", h[:6]) // 12 hex-символов
if b.registryProject != "" {
// project-режим: Harbor / GCR / GHCR
return fmt.Sprintf("%s/%s/%s:%s", b.registryHost, b.registryProject, funcName, tag)
}
// flat-режим: DockerHub (только 2 уровня)
nsPrefix := namespace
if len(nsPrefix) > 8 {
nsPrefix = nsPrefix[:8]
}
return fmt.Sprintf("%s/%s-%s:%s", b.registryHost, nsPrefix, funcName, tag)
}
// ImageExists проверяет наличие образа в Docker Registry v2 по тегу (без pull).
// Использует анонимный bearer-token для публичных репо (DockerHub).
// Возвращает true если образ с таким тегом уже запушен — сборка не нужна.
//
// Почему анонимный токен, а не credentials:
//
// DockerHub выдаёт pull-token без авторизации для публичных репо через
// GET /token?service=registry.docker.io&scope=repository:{repo}:pull
// Это стандартный Docker Registry v2 auth flow (RFC 7235).
func (b *Builder) ImageExists(ctx context.Context, imageRef string) bool {
// imageRef вида: "naeel/slessffd1-pg-search:47cab27ada70"
// или "host/project/func:tag" — разбираем по последнему ":"
colonIdx := strings.LastIndex(imageRef, ":")
if colonIdx < 0 {
return false
}
repoFull := imageRef[:colonIdx]
tag := imageRef[colonIdx+1:]
// Определяем registry host и repo path.
// DockerHub: "naeel/sless-ff-pg" → registry = index.docker.io, repo = "naeel/sless-ff-pg"
// Приватный: "harbor.host/proj/func" → registry = "harbor.host", repo = "proj/func"
registryHost := "index.docker.io"
repo := repoFull
if parts := strings.SplitN(repoFull, "/", 3); len(parts) == 3 {
// host/project/name — кастомный registry
registryHost = parts[0]
repo = parts[1] + "/" + parts[2]
}
// Получаем анонимный/публичный bearer-token для pull доступа к репо.
// DockerHub: https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull
// Для приватных registry этот шаг вернёт 401 → ImageExists вернёт false → пойдём строить.
tokenURL := fmt.Sprintf("https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull", repo)
if registryHost != "index.docker.io" {
// Для не-DockerHub registry: пробуем без токена (Harbor с allow anon push)
// Если 401 — false, пусть builder разберётся.
tokenURL = ""
}
httpClient := &http.Client{Timeout: 5 * time.Second}
var bearerToken string
if tokenURL != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
if err != nil {
return false
}
resp, err := httpClient.Do(req)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
defer resp.Body.Close()
var tkResp struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tkResp); err != nil {
return false
}
bearerToken = tkResp.Token
}
// HEAD /v2/{repo}/manifests/{tag} — проверяем наличие тега без скачивания слоёв.
manifestURL := fmt.Sprintf("https://%s/v2/%s/manifests/%s", registryHost, repo, tag)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, manifestURL, nil)
if err != nil {
return false
}
// Docker Registry v2 требует Accept header для манифестов.
req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json")
if bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+bearerToken)
}
resp, err := httpClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// 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"))
// kaniko читает Dockerfile из context архива в S3
// --context=s3://bucket/key — kaniko поддерживает S3 как источник контекста
s3ContextURL := fmt.Sprintf("s3://%s/%s", b.s3Bucket, s3Key)
job := &batchv1.Job{
ObjectMeta: metav1.ObjectMeta{
Name: jobName,
Namespace: b.namespace,
Labels: map[string]string{
"app": "sless-builder",
"function-name": funcName,
"function-ns": namespace,
},
},
Spec: batchv1.JobSpec{
// 2 попытки: при транзиентных сбоях (OOM, network blip) kaniko сможет перезапуститься.
// BackoffLimit=0 приводил к ложным "build job failed" при нагрузке.
BackoffLimit: int32Ptr(2),
Completions: int32Ptr(1),
Template: corev1.PodTemplateSpec{
Spec: corev1.PodSpec{
RestartPolicy: corev1.RestartPolicyNever,
Containers: []corev1.Container{
{
Name: "kaniko",
Image: b.builderImage,
Args: []string{
"--context=" + s3ContextURL,
"--destination=" + imageRef,
// --no-cache не поддерживается этой версией kaniko; кэш отключён по умолчанию
},
Env: []corev1.EnvVar{
{Name: "AWS_ACCESS_KEY_ID", Value: b.s3AccessKey},
{Name: "AWS_SECRET_ACCESS_KEY", Value: b.s3SecretKey},
// Kaniko AWS SDK требует схему в endpoint (https://) — без неё URL строится без протокола
{Name: "S3_ENDPOINT", Value: "https://" + b.s3Endpoint},
// Ceph не поддерживает virtual-hosted style (bucket.endpoint) — только path style
{Name: "S3_FORCE_PATH_STYLE", Value: "true"},
{Name: "AWS_REGION", Value: "us-east-1"},
},
// Монтируем docker-креды из k8s Secret — kaniko читает авторизацию из /kaniko/.docker/config.json
VolumeMounts: b.dockerConfigMounts(),
},
},
Volumes: b.dockerConfigVolumes(),
},
},
},
}
if err := b.client.Create(ctx, job); err != nil {
// Если job с таким именем уже существует (два reconcile в одну секунду) —
// возвращаем имя, контроллер будет следить за ним как обычно
if errors.IsAlreadyExists(err) {
return jobName, nil
}
return "", fmt.Errorf("create build job: %w", err)
}
return jobName, nil
}
// JobStatus проверяет статус build Job'а.
// Возвращает: "running", "succeeded", "failed"
func (b *Builder) JobStatus(ctx context.Context, jobName string) (string, error) {
job := &batchv1.Job{}
if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil {
if errors.IsNotFound(err) {
return "failed", nil
}
return "", fmt.Errorf("get build job: %w", err)
}
if job.Status.Succeeded > 0 {
return "succeeded", nil
}
if job.Status.Failed > 0 {
return "failed", nil
}
return "running", nil
}
// Cleanup удаляет завершённый build Job из k8s.
// Вызывается после того как контроллер зафиксировал результат сборки.
func (b *Builder) Cleanup(ctx context.Context, jobName string) error {
job := &batchv1.Job{}
if err := b.client.Get(ctx, client.ObjectKey{Name: jobName, Namespace: b.namespace}, job); err != nil {
if errors.IsNotFound(err) {
return nil // уже удалён
}
return fmt.Errorf("get build job for cleanup: %w", err)
}
propagation := metav1.DeletePropagationForeground
return b.client.Delete(ctx, job, &client.DeleteOptions{PropagationPolicy: &propagation})
}
// dockerConfigVolumes возвращает Volume с docker-кредами если RegistrySecret задан.
// Ключ .dockerconfigjson монтируется как config.json — стандартное имя для kaniko.
func (b *Builder) dockerConfigVolumes() []corev1.Volume {
if b.registrySecret == "" {
return nil
}
return []corev1.Volume{
{
Name: "docker-config",
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: b.registrySecret,
Items: []corev1.KeyToPath{
{Key: ".dockerconfigjson", Path: "config.json"},
},
},
},
},
}
}
// dockerConfigMounts возвращает VolumeMount для docker-кредов если RegistrySecret задан.
func (b *Builder) dockerConfigMounts() []corev1.VolumeMount {
if b.registrySecret == "" {
return nil
}
return []corev1.VolumeMount{
{Name: "docker-config", MountPath: "/kaniko/.docker"},
}
}
func int32Ptr(i int32) *int32 { return &i }