Files
sless/internal/builder/builder.go
T
Naeel d286d92a05 fix: invoke.go — forward Content-Length to proxied request (form POST fix)
Without ContentLength, Python BaseHTTPRequestHandler read 0 bytes from body.
operator v0.1.37, python runtime v0.1.4, pg-table-writer HTML form
2026-03-19 08:58:35 +03:00

225 lines
9.1 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-11
// Builder — запускает kaniko Job в k8s для сборки Docker образа из кода функции.
// Почему kaniko, а не docker-in-docker (DinD):
// kaniko не требует privileged контейнер, что безопаснее в managed кластере.
// kaniko читает контекст сборки из S3 напрямую.
// Workflow: FunctionController вызывает Build → EnsureProject (Harbor) → Job запускается → образ пушится в registry.
package builder
import (
"context"
"crypto/sha256"
"fmt"
"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 // куда пушим образ (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'а.
type Config struct {
BuilderImage string
RegistryHost string
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,
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.
// Формат: {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/%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"))
// 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", // без кэша — гарантирует что COPY берёт свежий код из S3
},
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 }