333 lines
14 KiB
Go
333 lines
14 KiB
Go
// Изменено: 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 }
|