mirror of
https://github.com/run-llama/workflows-py.git
synced 2026-07-21 04:05:25 -04:00
1383 lines
51 KiB
Go
1383 lines
51 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
batchv1 "k8s.io/api/batch/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/util/intstr"
|
|
"k8s.io/apimachinery/pkg/util/strategicpatch"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/log"
|
|
|
|
llamadeployv1 "llama-agents-operator/api/v1"
|
|
)
|
|
|
|
const (
|
|
// Container image configuration
|
|
DefaultImage = "llamaindex/llama-agents-appserver"
|
|
DefaultImageTag = "latest"
|
|
|
|
// Environment variables for image configuration
|
|
EnvImageName = "LLAMA_DEPLOY_IMAGE"
|
|
EnvImageTag = "LLAMA_DEPLOY_IMAGE_TAG"
|
|
EnvImagePullPolicy = "LLAMA_DEPLOY_IMAGE_PULL_POLICY"
|
|
|
|
// Nginx sidecar image configuration (configurable via Helm → operator env)
|
|
DefaultNginxImage = "nginxinc/nginx-unprivileged"
|
|
DefaultNginxImageTag = "1.27-alpine"
|
|
EnvNginxImageName = "LLAMA_DEPLOY_NGINX_IMAGE"
|
|
EnvNginxImageTag = "LLAMA_DEPLOY_NGINX_IMAGE_TAG"
|
|
EnvNginxImagePullPolicy = "LLAMA_DEPLOY_NGINX_IMAGE_PULL_POLICY"
|
|
|
|
// Default resource request env overrides for app container
|
|
EnvDefaultCPURequest = "LLAMA_DEPLOY_DEFAULT_CPU_REQUEST"
|
|
EnvDefaultMemoryRequest = "LLAMA_DEPLOY_DEFAULT_MEMORY_REQUEST"
|
|
|
|
// Default resource limit env overrides for app container
|
|
// CPU limit defaults to unset (empty); memory limit defaults to 4096Mi
|
|
EnvDefaultCPULimit = "LLAMA_DEPLOY_DEFAULT_CPU_LIMIT"
|
|
EnvDefaultMemoryLimit = "LLAMA_DEPLOY_DEFAULT_MEMORY_LIMIT"
|
|
|
|
// Container name constants
|
|
ContainerNameApp = "app"
|
|
ContainerNameBuild = "build"
|
|
|
|
// Container user/group IDs
|
|
AppServerUID int64 = 1001
|
|
AppServerGID int64 = 1001
|
|
NginxUID int64 = 101
|
|
NginxGID int64 = 101
|
|
|
|
// Default pull policy
|
|
DefaultImagePullPolicy = "IfNotPresent"
|
|
|
|
// Default rollout timeout in seconds (30 minutes)
|
|
DefaultRolloutTimeoutSeconds int32 = 1800
|
|
|
|
// Build artifact GC walks ReplicaSets up to this limit to discover
|
|
// referenced buildIds, so pin it rather than relying on the apps/v1 default.
|
|
DeploymentRevisionHistoryLimit int32 = 10
|
|
|
|
// Environment variable for rollout timeout configuration
|
|
EnvRolloutTimeoutSeconds = "LLAMA_DEPLOY_ROLLOUT_TIMEOUT_SECONDS"
|
|
|
|
// appserverTagPrefix is retained for backward compatibility with old-style
|
|
// image tags like "appserver-0.8.1". New tags are plain versions ("0.8.1").
|
|
appserverTagPrefix = "appserver-"
|
|
)
|
|
|
|
// buildJobName computes the Job name for a given deployment/buildId combo,
|
|
// truncated to Kubernetes' 63-character name limit.
|
|
func buildJobName(deploymentName, buildId string) string {
|
|
name := fmt.Sprintf("%s-build-%s", deploymentName, buildId)
|
|
if len(name) > 63 {
|
|
name = name[:63]
|
|
}
|
|
return name
|
|
}
|
|
|
|
// looksLikeFilePath provides a lightweight heuristic to determine if a
|
|
// deployment file path refers to a file rather than a directory. It avoids
|
|
// filesystem access and relies on common file extensions and presence of an extension.
|
|
func looksLikeFilePath(p string) bool {
|
|
base := path.Base(p)
|
|
if p == "." || p == "/" {
|
|
return false
|
|
}
|
|
if strings.Contains(base, ".") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getDefaultImage returns the operator-level default container image,
|
|
// ignoring any per-deployment spec overrides.
|
|
// 1. Use environment variable LLAMA_DEPLOY_IMAGE if set
|
|
// 2. Fall back to default: "llamaindex/llama-agents-appserver"
|
|
func getDefaultImage() string {
|
|
if envImage := os.Getenv(EnvImageName); envImage != "" {
|
|
return envImage
|
|
}
|
|
return DefaultImage
|
|
}
|
|
|
|
// getDefaultImageTag returns the operator-level default container image tag,
|
|
// ignoring any per-deployment spec overrides.
|
|
// 1. Use environment variable LLAMA_DEPLOY_IMAGE_TAG if set
|
|
// 2. Fall back to default: "latest"
|
|
func getDefaultImageTag() string {
|
|
if envTag := os.Getenv(EnvImageTag); envTag != "" {
|
|
return envTag
|
|
}
|
|
return DefaultImageTag
|
|
}
|
|
|
|
// getContainerImage returns the container image to use, with fallback logic:
|
|
// 1. Use spec.Image if specified
|
|
// 2. Use environment variable LLAMA_DEPLOY_IMAGE if set
|
|
// 3. Fall back to default: "llamaindex/llama-agents-appserver"
|
|
func getContainerImage(llamaDeploy *llamadeployv1.LlamaDeployment) string {
|
|
if llamaDeploy.Spec.Image != "" {
|
|
return llamaDeploy.Spec.Image
|
|
}
|
|
if envImage := os.Getenv(EnvImageName); envImage != "" {
|
|
return envImage
|
|
}
|
|
return DefaultImage
|
|
}
|
|
|
|
// getContainerImageTag returns the container image tag to use, with fallback logic:
|
|
// 1. Use spec.ImageTag if specified (per-deployment pinning via control plane)
|
|
// 2. Use environment variable LLAMA_DEPLOY_IMAGE_TAG if set (fallback for CRDs without imageTag)
|
|
// 3. Fall back to default: "appserver-latest"
|
|
func getContainerImageTag(llamaDeploy *llamadeployv1.LlamaDeployment) string {
|
|
if llamaDeploy.Spec.ImageTag != "" {
|
|
// Backward compat: strip legacy "appserver-" prefix if present.
|
|
// New-style tags are plain versions (e.g. "0.8.1"), old-style are "appserver-0.8.1".
|
|
return strings.TrimPrefix(llamaDeploy.Spec.ImageTag, appserverTagPrefix)
|
|
}
|
|
if envTag := os.Getenv(EnvImageTag); envTag != "" {
|
|
return envTag
|
|
}
|
|
return DefaultImageTag
|
|
}
|
|
|
|
// getContainerImagePullPolicy returns the container image pull policy to use, with fallback logic:
|
|
// 1. Use environment variable LLAMA_DEPLOY_IMAGE_PULL_POLICY if set
|
|
// 2. Fall back to default: "IfNotPresent"
|
|
func getContainerImagePullPolicy() corev1.PullPolicy {
|
|
if envPullPolicy := os.Getenv(EnvImagePullPolicy); envPullPolicy != "" {
|
|
switch envPullPolicy {
|
|
case "Always":
|
|
return corev1.PullAlways
|
|
case "Never":
|
|
return corev1.PullNever
|
|
case "IfNotPresent":
|
|
return corev1.PullIfNotPresent
|
|
default:
|
|
// Log warning for invalid value and use default
|
|
return corev1.PullIfNotPresent
|
|
}
|
|
}
|
|
return corev1.PullIfNotPresent
|
|
}
|
|
|
|
// getPullPolicyFromEnv returns a pull policy based on the given env var name.
|
|
func getPullPolicyFromEnv(envVarName string) corev1.PullPolicy {
|
|
if envPullPolicy := os.Getenv(envVarName); envPullPolicy != "" {
|
|
switch envPullPolicy {
|
|
case "Always":
|
|
return corev1.PullAlways
|
|
case "Never":
|
|
return corev1.PullNever
|
|
case "IfNotPresent":
|
|
return corev1.PullIfNotPresent
|
|
default:
|
|
return corev1.PullIfNotPresent
|
|
}
|
|
}
|
|
return corev1.PullIfNotPresent
|
|
}
|
|
|
|
// getNginxImage returns the nginx sidecar image repository to use.
|
|
func getNginxImage() string {
|
|
if envImage := os.Getenv(EnvNginxImageName); envImage != "" {
|
|
return envImage
|
|
}
|
|
return DefaultNginxImage
|
|
}
|
|
|
|
// getNginxImageTag returns the nginx sidecar image tag to use.
|
|
func getNginxImageTag() string {
|
|
if envTag := os.Getenv(EnvNginxImageTag); envTag != "" {
|
|
return envTag
|
|
}
|
|
return DefaultNginxImageTag
|
|
}
|
|
|
|
// getNginxImagePullPolicy returns the nginx sidecar image pull policy to use.
|
|
func getNginxImagePullPolicy() corev1.PullPolicy {
|
|
return getPullPolicyFromEnv(EnvNginxImagePullPolicy)
|
|
}
|
|
|
|
// parseOptionalQuantityFromEnv reads a quantity from an env var.
|
|
// Returns (nil, true) if the env var is present but intentionally unset
|
|
// (empty/none/unset/null/nil). Returns (quantity, true) if present and valid;
|
|
// (nil, false) if not present or invalid.
|
|
func parseOptionalQuantityFromEnv(envVarName string) (*resource.Quantity, bool) {
|
|
raw, present := os.LookupEnv(envVarName)
|
|
if !present {
|
|
return nil, false
|
|
}
|
|
v := strings.TrimSpace(raw)
|
|
if v == "" {
|
|
return nil, true
|
|
}
|
|
lower := strings.ToLower(v)
|
|
if lower == "none" || lower == "unset" || lower == "null" || lower == "nil" {
|
|
return nil, true
|
|
}
|
|
if q, err := resource.ParseQuantity(v); err == nil {
|
|
return &q, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// getDefaultResourceRequests constructs the default ResourceList for requests.
|
|
// Defaults: CPU 750m, memory 2Gi. If env var is present but empty/none, the field is omitted.
|
|
func getDefaultResourceRequests() corev1.ResourceList {
|
|
requests := corev1.ResourceList{}
|
|
if q, present := parseOptionalQuantityFromEnv(EnvDefaultCPURequest); present {
|
|
if q != nil {
|
|
requests[corev1.ResourceCPU] = *q
|
|
}
|
|
} else {
|
|
requests[corev1.ResourceCPU] = resource.MustParse("750m")
|
|
}
|
|
if q, present := parseOptionalQuantityFromEnv(EnvDefaultMemoryRequest); present {
|
|
if q != nil {
|
|
requests[corev1.ResourceMemory] = *q
|
|
}
|
|
} else {
|
|
requests[corev1.ResourceMemory] = resource.MustParse("2Gi")
|
|
}
|
|
return requests
|
|
}
|
|
|
|
// getDefaultResourceLimits constructs the default ResourceList for limits.
|
|
// Defaults: CPU unset, memory 4096Mi. If env var is present but empty/none, the field is omitted.
|
|
func getDefaultResourceLimits() corev1.ResourceList {
|
|
limits := corev1.ResourceList{}
|
|
if q, present := parseOptionalQuantityFromEnv(EnvDefaultCPULimit); present {
|
|
if q != nil {
|
|
limits[corev1.ResourceCPU] = *q
|
|
}
|
|
}
|
|
if q, present := parseOptionalQuantityFromEnv(EnvDefaultMemoryLimit); present {
|
|
if q != nil {
|
|
limits[corev1.ResourceMemory] = *q
|
|
}
|
|
} else {
|
|
limits[corev1.ResourceMemory] = resource.MustParse("4096Mi")
|
|
}
|
|
return limits
|
|
}
|
|
|
|
// defaultPodSecurityContext returns the shared pod-level security context.
|
|
func defaultPodSecurityContext() *corev1.PodSecurityContext {
|
|
return &corev1.PodSecurityContext{
|
|
FSGroup: ptr(AppServerGID),
|
|
}
|
|
}
|
|
|
|
// defaultContainerSecurityContext returns the hardened container security context
|
|
// for containers running as the appserver user (uid/gid 1001).
|
|
func defaultContainerSecurityContext() *corev1.SecurityContext {
|
|
return &corev1.SecurityContext{
|
|
RunAsNonRoot: ptr(true),
|
|
RunAsUser: ptr(AppServerUID),
|
|
RunAsGroup: ptr(AppServerGID),
|
|
AllowPrivilegeEscalation: ptr(false),
|
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
|
}
|
|
}
|
|
|
|
// hardenedSecurityContext returns a minimal hardened security context that drops
|
|
// all capabilities and disables privilege escalation, without setting uid/gid
|
|
// (for containers whose uid is set by the image, e.g. the appserver).
|
|
func hardenedSecurityContext() *corev1.SecurityContext {
|
|
return &corev1.SecurityContext{
|
|
AllowPrivilegeEscalation: ptr(false),
|
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
|
}
|
|
}
|
|
|
|
// getRolloutTimeout returns the configured rollout timeout duration.
|
|
// Reads from LLAMA_DEPLOY_ROLLOUT_TIMEOUT_SECONDS env var, falling back to DefaultRolloutTimeoutSeconds.
|
|
func getRolloutTimeout() time.Duration {
|
|
return time.Duration(getRolloutTimeoutSecondsValue()) * time.Second
|
|
}
|
|
|
|
// getRolloutTimeoutSeconds returns the configured rollout timeout as *int32.
|
|
func getRolloutTimeoutSeconds() *int32 {
|
|
v := getRolloutTimeoutSecondsValue()
|
|
return &v
|
|
}
|
|
|
|
// getRolloutTimeoutSecondsValue reads the timeout from env var or returns the default.
|
|
func getRolloutTimeoutSecondsValue() int32 {
|
|
if raw := os.Getenv(EnvRolloutTimeoutSeconds); raw != "" {
|
|
if v, err := strconv.ParseInt(raw, 10, 32); err == nil && v > 0 {
|
|
return int32(v)
|
|
}
|
|
}
|
|
return DefaultRolloutTimeoutSeconds
|
|
}
|
|
|
|
// isValidDNS1035Label validates that a name is a valid DNS-1035 label.
|
|
// DNS-1035 labels must: start with [a-z], end with [a-z0-9], contain only
|
|
// [a-z0-9-], and be 1-63 characters long.
|
|
func isValidDNS1035Label(name string) bool {
|
|
if len(name) == 0 || len(name) > 63 {
|
|
return false
|
|
}
|
|
for i := 0; i < len(name); i++ {
|
|
c := name[i]
|
|
if c >= 'a' && c <= 'z' {
|
|
continue
|
|
}
|
|
if c >= '0' && c <= '9' {
|
|
if i == 0 {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if c == '-' {
|
|
if i == 0 || i == len(name)-1 {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// computeBuildId computes a deterministic build identifier from the inputs that
|
|
// affect build output: name + gitSha + buildGeneration. spec.ImageTag is NOT
|
|
// included because the build Job always uses the operator's default image, not
|
|
// the deployment's pinned image.
|
|
func computeBuildId(llamaDeploy *llamadeployv1.LlamaDeployment) string {
|
|
parts := []string{llamaDeploy.Name, llamaDeploy.Spec.GitSha}
|
|
if llamaDeploy.Spec.BuildGeneration > 0 {
|
|
parts = append(parts, fmt.Sprintf("bg:%d", llamaDeploy.Spec.BuildGeneration))
|
|
}
|
|
content := strings.Join(parts, "|")
|
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(content)))
|
|
return hash[:16]
|
|
}
|
|
|
|
// supersedeStaleBuildJob cancels any in-flight build Job whose buildId has
|
|
// been superseded by a new spec. Succeeded stale Jobs are preserved for the
|
|
// A → B → A rollback-by-cache-hit path. A missing Job is a no-op (TTL may
|
|
// have reaped it, or a prior reconcile deleted it).
|
|
func (r *LlamaDeploymentReconciler) supersedeStaleBuildJob(
|
|
ctx context.Context,
|
|
llamaDeploy *llamadeployv1.LlamaDeployment,
|
|
newBuildId string,
|
|
) error {
|
|
if llamaDeploy.Status.BuildId == "" || llamaDeploy.Status.BuildId == newBuildId {
|
|
return nil
|
|
}
|
|
if llamaDeploy.Status.BuildStatus != BuildStatusPending &&
|
|
llamaDeploy.Status.BuildStatus != BuildStatusRunning {
|
|
return nil
|
|
}
|
|
|
|
logger := log.FromContext(ctx)
|
|
staleBuildId := llamaDeploy.Status.BuildId
|
|
staleJobName := buildJobName(llamaDeploy.Name, staleBuildId)
|
|
staleJob := &batchv1.Job{}
|
|
getErr := r.Get(ctx, client.ObjectKey{Name: staleJobName, Namespace: llamaDeploy.Namespace}, staleJob)
|
|
switch {
|
|
case errors.IsNotFound(getErr):
|
|
return nil
|
|
case getErr != nil:
|
|
return fmt.Errorf("failed to check stale build job: %w", getErr)
|
|
case staleJob.Status.Succeeded > 0:
|
|
logger.V(1).Info("Prior build succeeded; leaving Job in place",
|
|
"staleBuildId", staleBuildId, "jobName", staleJobName)
|
|
return nil
|
|
}
|
|
|
|
logger.Info("Superseding in-flight build Job for previous buildId",
|
|
"staleBuildId", staleBuildId,
|
|
"newBuildId", newBuildId,
|
|
"jobName", staleJobName)
|
|
if err := r.Delete(ctx, staleJob,
|
|
client.PropagationPolicy(metav1.DeletePropagationBackground),
|
|
); err != nil && !errors.IsNotFound(err) {
|
|
return fmt.Errorf("failed to delete superseded build job: %w", err)
|
|
}
|
|
if r.Recorder != nil {
|
|
r.Recorder.Event(llamaDeploy, corev1.EventTypeNormal, PhaseBuilding,
|
|
fmt.Sprintf("Superseded stale build Job: %s", staleJobName))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileBuild ensures a build artifact exists for the current deployment inputs.
|
|
// Returns the buildId if a build is ready, or ("", result, nil) if a build is in progress
|
|
// and the caller should requeue.
|
|
func (r *LlamaDeploymentReconciler) reconcileBuild(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment) (string, *ctrl.Result, error) {
|
|
logger := log.FromContext(ctx)
|
|
|
|
// No code source configured — wait for a git push to set RepoUrl.
|
|
if isAwaitingCodePush(llamaDeploy) {
|
|
logger.Info("Skipping build: RepoUrl is empty, waiting for code push")
|
|
return "", nil, nil
|
|
}
|
|
|
|
// Suspended deployments should not start or continue builds unless
|
|
// spec.buildGeneration was explicitly bumped past status.lastBuiltGeneration.
|
|
// This lets operators pre-build artifacts for suspended deployments
|
|
// so that unsuspending is instant.
|
|
if llamaDeploy.Spec.Suspended &&
|
|
llamaDeploy.Spec.BuildGeneration <= llamaDeploy.Status.LastBuiltGeneration {
|
|
logger.V(1).Info("Skipping build for suspended deployment")
|
|
return "", nil, nil
|
|
}
|
|
|
|
buildId := computeBuildId(llamaDeploy)
|
|
|
|
// If the CRD already has this buildId marked as succeeded, skip the build
|
|
if llamaDeploy.Status.BuildId == buildId && llamaDeploy.Status.BuildStatus == BuildStatusSucceeded {
|
|
logger.V(1).Info("Build artifact already exists", "buildId", buildId)
|
|
return buildId, nil, nil
|
|
}
|
|
|
|
// If the spec advanced mid-build, cancel the stale in-flight Job so it
|
|
// doesn't race the new one uploading.
|
|
if err := r.supersedeStaleBuildJob(ctx, llamaDeploy, buildId); err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
// Check if a build Job already exists for this buildId
|
|
jobName := buildJobName(llamaDeploy.Name, buildId)
|
|
|
|
existingJob := &batchv1.Job{}
|
|
err := r.Get(ctx, client.ObjectKey{Name: jobName, Namespace: llamaDeploy.Namespace}, existingJob)
|
|
|
|
if err != nil && !errors.IsNotFound(err) {
|
|
return "", nil, fmt.Errorf("failed to check build job: %w", err)
|
|
}
|
|
|
|
if errors.IsNotFound(err) {
|
|
// No existing job — create one
|
|
logger.Info("Creating build job", "buildId", buildId, "jobName", jobName)
|
|
|
|
job := r.createBuildJob(llamaDeploy, buildId)
|
|
if err := r.applyBuildJobTemplateOverlay(ctx, llamaDeploy, job); err != nil {
|
|
logger.Error(err, "Failed to apply template overlay to build job")
|
|
// Continue without overlay — scheduling preferences are optional
|
|
}
|
|
if err := controllerutil.SetControllerReference(llamaDeploy, job, r.Scheme); err != nil {
|
|
return "", nil, fmt.Errorf("failed to set owner reference on build job: %w", err)
|
|
}
|
|
if err := r.Create(ctx, job); err != nil {
|
|
if errors.IsAlreadyExists(err) {
|
|
// Race condition — another reconcile created it. Requeue to check status.
|
|
result := ctrl.Result{RequeueAfter: 5 * time.Second}
|
|
return "", &result, nil
|
|
}
|
|
return "", nil, fmt.Errorf("failed to create build job: %w", err)
|
|
}
|
|
|
|
// Update CRD status to Building
|
|
llamaDeploy.Status.BuildId = buildId
|
|
llamaDeploy.Status.BuildStatus = BuildStatusPending
|
|
llamaDeploy.Status.Phase = PhaseBuilding
|
|
llamaDeploy.Status.Message = fmt.Sprintf("Build job created: %s", jobName)
|
|
now := metav1.Now()
|
|
llamaDeploy.Status.LastUpdated = &now
|
|
if err := r.Status().Update(ctx, llamaDeploy); err != nil {
|
|
logger.Error(err, "Failed to update build status")
|
|
return "", nil, err
|
|
}
|
|
if r.Recorder != nil {
|
|
r.Recorder.Event(llamaDeploy, corev1.EventTypeNormal, PhaseBuilding, fmt.Sprintf("Build job created: %s", jobName))
|
|
}
|
|
|
|
result := ctrl.Result{RequeueAfter: 60 * time.Second}
|
|
return "", &result, nil
|
|
}
|
|
|
|
// Job exists — check its status
|
|
if existingJob.Status.Succeeded > 0 {
|
|
logger.Info("Build job succeeded", "buildId", buildId, "jobName", jobName)
|
|
|
|
// Update CRD status
|
|
llamaDeploy.Status.BuildId = buildId
|
|
llamaDeploy.Status.BuildStatus = BuildStatusSucceeded
|
|
llamaDeploy.Status.LastBuiltGeneration = llamaDeploy.Spec.BuildGeneration
|
|
now := metav1.Now()
|
|
llamaDeploy.Status.LastUpdated = &now
|
|
if err := r.Status().Update(ctx, llamaDeploy); err != nil {
|
|
logger.Error(err, "Failed to update build status to Succeeded")
|
|
return "", nil, err
|
|
}
|
|
|
|
return buildId, nil, nil
|
|
}
|
|
|
|
if existingJob.Status.Failed > 0 {
|
|
// If generation advanced (user wants a retry), delete the stale Job
|
|
// and fall through to create a new one
|
|
if llamaDeploy.Status.FailedRolloutGeneration != llamaDeploy.Generation {
|
|
logger.Info("Generation advanced past failed build, deleting stale job for retry",
|
|
"buildId", buildId, "jobName", jobName,
|
|
"failedGeneration", llamaDeploy.Status.FailedRolloutGeneration,
|
|
"currentGeneration", llamaDeploy.Generation)
|
|
if err := r.Delete(ctx, existingJob, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil && !errors.IsNotFound(err) {
|
|
return "", nil, fmt.Errorf("failed to delete stale build job: %w", err)
|
|
}
|
|
// Record that we've consumed this generation's retry attempt.
|
|
// If the new Job also fails, the "same generation" path below
|
|
// will mark it as a genuine BuildFailed instead of looping.
|
|
llamaDeploy.Status.FailedRolloutGeneration = llamaDeploy.Generation
|
|
if err := r.Status().Update(ctx, llamaDeploy); err != nil {
|
|
logger.Error(err, "Failed to update failedRolloutGeneration after retry")
|
|
return "", nil, err
|
|
}
|
|
// Requeue to let the deletion propagate, then create a new Job
|
|
result := ctrl.Result{RequeueAfter: 5 * time.Second}
|
|
return "", &result, nil
|
|
}
|
|
|
|
// Same generation — genuine failure, mark as BuildFailed
|
|
logger.Info("Build job failed", "buildId", buildId, "jobName", jobName)
|
|
|
|
llamaDeploy.Status.BuildId = buildId
|
|
llamaDeploy.Status.BuildStatus = BuildStatusFailed
|
|
llamaDeploy.Status.Phase = PhaseBuildFailed
|
|
llamaDeploy.Status.Message = fmt.Sprintf("Build job failed: %s", jobName)
|
|
now := metav1.Now()
|
|
llamaDeploy.Status.LastUpdated = &now
|
|
llamaDeploy.Status.FailedRolloutGeneration = llamaDeploy.Generation
|
|
if err := r.Status().Update(ctx, llamaDeploy); err != nil {
|
|
logger.Error(err, "Failed to update build status to Failed")
|
|
return "", nil, err
|
|
}
|
|
if r.Recorder != nil {
|
|
r.Recorder.Event(llamaDeploy, corev1.EventTypeWarning, PhaseBuildFailed, fmt.Sprintf("Build job failed: %s", jobName))
|
|
}
|
|
|
|
// Don't requeue — wait for spec change (new generation)
|
|
result := ctrl.Result{}
|
|
return "", &result, nil
|
|
}
|
|
|
|
// Job is still running
|
|
if llamaDeploy.Status.BuildStatus != BuildStatusRunning {
|
|
llamaDeploy.Status.BuildStatus = BuildStatusRunning
|
|
llamaDeploy.Status.Phase = PhaseBuilding
|
|
llamaDeploy.Status.Message = fmt.Sprintf("Build in progress: %s", jobName)
|
|
now := metav1.Now()
|
|
llamaDeploy.Status.LastUpdated = &now
|
|
if err := r.Status().Update(ctx, llamaDeploy); err != nil {
|
|
logger.Error(err, "Failed to update build status to Running")
|
|
}
|
|
}
|
|
|
|
result := ctrl.Result{RequeueAfter: 60 * time.Second}
|
|
return "", &result, nil
|
|
}
|
|
|
|
// createBuildJob creates a Job that runs the build process and uploads artifacts to S3.
|
|
func (r *LlamaDeploymentReconciler) createBuildJob(llamaDeploy *llamadeployv1.LlamaDeployment, buildId string) *batchv1.Job {
|
|
// Build environment variables from common helper, plus build-specific BUILD_ID
|
|
envVars := append(r.commonEnvVars(llamaDeploy), corev1.EnvVar{
|
|
Name: "LLAMA_DEPLOY_BUILD_ID",
|
|
Value: buildId,
|
|
})
|
|
|
|
envFrom := r.commonEnvFrom(llamaDeploy)
|
|
|
|
jobName := buildJobName(llamaDeploy.Name, buildId)
|
|
|
|
return &batchv1.Job{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: jobName,
|
|
Namespace: llamaDeploy.Namespace,
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/managed-by": "llama-deploy-operator",
|
|
"deploy.llamaindex.ai/deployment": llamaDeploy.Name,
|
|
"deploy.llamaindex.ai/build-id": buildId,
|
|
},
|
|
},
|
|
Spec: batchv1.JobSpec{
|
|
BackoffLimit: ptr(int32(1)),
|
|
TTLSecondsAfterFinished: ptr(int32(3600)),
|
|
ActiveDeadlineSeconds: ptr(int64(1800)),
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/managed-by": "llama-deploy-operator",
|
|
"deploy.llamaindex.ai/deployment": llamaDeploy.Name,
|
|
"deploy.llamaindex.ai/build-id": buildId,
|
|
},
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
SecurityContext: defaultPodSecurityContext(),
|
|
RestartPolicy: corev1.RestartPolicyNever,
|
|
Volumes: []corev1.Volume{
|
|
{
|
|
Name: "app-data",
|
|
VolumeSource: corev1.VolumeSource{
|
|
EmptyDir: &corev1.EmptyDirVolumeSource{},
|
|
},
|
|
},
|
|
},
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: ContainerNameBuild,
|
|
Image: fmt.Sprintf("%s:%s", getDefaultImage(), getDefaultImageTag()),
|
|
ImagePullPolicy: getContainerImagePullPolicy(),
|
|
Command: []string{"python", "-m", "llama_deploy.appserver.build"},
|
|
Env: envVars,
|
|
EnvFrom: envFrom,
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{Name: "app-data", MountPath: "/opt/app"},
|
|
},
|
|
Resources: corev1.ResourceRequirements{Requests: getDefaultResourceRequests(), Limits: getDefaultResourceLimits()},
|
|
SecurityContext: defaultContainerSecurityContext(),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// applyBuildJobTemplateOverlay applies scheduling fields from a LlamaDeploymentTemplate to a build Job.
|
|
// Pod-level scheduling fields are always applied (nodeSelector, tolerations, affinity, priorityClassName,
|
|
// serviceAccountName). Container-level resource overrides are merged (not replaced) from a "build"
|
|
// container if present in the template, otherwise from the "app" container as a fallback.
|
|
func (r *LlamaDeploymentReconciler) applyBuildJobTemplateOverlay(ctx context.Context, ld *llamadeployv1.LlamaDeployment, job *batchv1.Job) error {
|
|
templateName := ld.Spec.TemplateName
|
|
if templateName == "" {
|
|
templateName = "default"
|
|
}
|
|
|
|
tmpl := &llamadeployv1.LlamaDeploymentTemplate{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: templateName, Namespace: ld.Namespace}, tmpl); err != nil {
|
|
if errors.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
overlay := tmpl.Spec.PodSpec.Spec
|
|
|
|
// Apply pod-level scheduling fields
|
|
if overlay.NodeSelector != nil {
|
|
job.Spec.Template.Spec.NodeSelector = overlay.NodeSelector
|
|
}
|
|
if overlay.Tolerations != nil {
|
|
job.Spec.Template.Spec.Tolerations = overlay.Tolerations
|
|
}
|
|
if overlay.Affinity != nil {
|
|
job.Spec.Template.Spec.Affinity = overlay.Affinity
|
|
}
|
|
if overlay.PriorityClassName != "" {
|
|
job.Spec.Template.Spec.PriorityClassName = overlay.PriorityClassName
|
|
}
|
|
if overlay.ServiceAccountName != "" {
|
|
job.Spec.Template.Spec.ServiceAccountName = overlay.ServiceAccountName
|
|
}
|
|
if overlay.SecurityContext != nil {
|
|
job.Spec.Template.Spec.SecurityContext = overlay.SecurityContext
|
|
}
|
|
|
|
// Merge template metadata (annotations and labels) into the build job pod template
|
|
overlayMeta := tmpl.Spec.PodSpec.ObjectMeta
|
|
if len(overlayMeta.Annotations) > 0 {
|
|
if job.Spec.Template.Annotations == nil {
|
|
job.Spec.Template.Annotations = make(map[string]string)
|
|
}
|
|
for k, v := range overlayMeta.Annotations {
|
|
job.Spec.Template.Annotations[k] = v
|
|
}
|
|
}
|
|
if len(overlayMeta.Labels) > 0 {
|
|
if job.Spec.Template.Labels == nil {
|
|
job.Spec.Template.Labels = make(map[string]string)
|
|
}
|
|
for k, v := range overlayMeta.Labels {
|
|
job.Spec.Template.Labels[k] = v
|
|
}
|
|
}
|
|
|
|
// Build the final resource requirements by starting from the template and
|
|
// backfilling defaults for any keys the template doesn't set. Look for a
|
|
// dedicated "build" container first, falling back to "app".
|
|
templateResources := findContainerResources(overlay.Containers, ContainerNameBuild)
|
|
if templateResources == nil {
|
|
templateResources = findContainerResources(overlay.Containers, ContainerNameApp)
|
|
}
|
|
if templateResources != nil {
|
|
for i := range job.Spec.Template.Spec.Containers {
|
|
if job.Spec.Template.Spec.Containers[i].Name == ContainerNameBuild {
|
|
job.Spec.Template.Spec.Containers[i].Resources = mergeResourceRequirements(templateResources, &job.Spec.Template.Spec.Containers[i].Resources)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Propagate container-level SecurityContext from the template overlay
|
|
templateSC := findContainerSecurityContext(overlay.Containers, ContainerNameBuild)
|
|
if templateSC == nil {
|
|
templateSC = findContainerSecurityContext(overlay.Containers, ContainerNameApp)
|
|
}
|
|
if templateSC != nil {
|
|
for i := range job.Spec.Template.Spec.Containers {
|
|
if job.Spec.Template.Spec.Containers[i].Name == ContainerNameBuild {
|
|
job.Spec.Template.Spec.Containers[i].SecurityContext = templateSC
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// findContainerSecurityContext returns the SecurityContext for the named container,
|
|
// or nil if the container is not found or has no security context set.
|
|
func findContainerSecurityContext(containers []corev1.Container, name string) *corev1.SecurityContext {
|
|
for _, c := range containers {
|
|
if c.Name == name {
|
|
if c.SecurityContext != nil {
|
|
return c.SecurityContext
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findContainerResources returns the ResourceRequirements for the named container,
|
|
// or nil if the container is not found or has no resources set.
|
|
func findContainerResources(containers []corev1.Container, name string) *corev1.ResourceRequirements {
|
|
for _, c := range containers {
|
|
if c.Name == name {
|
|
if c.Resources.Requests != nil || c.Resources.Limits != nil {
|
|
return &c.Resources
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mergeResourceRequirements starts from the overlay resources and backfills any
|
|
// keys from defaults that the overlay doesn't set.
|
|
func mergeResourceRequirements(overlay, defaults *corev1.ResourceRequirements) corev1.ResourceRequirements {
|
|
merged := overlay.DeepCopy()
|
|
if merged.Requests == nil {
|
|
merged.Requests = corev1.ResourceList{}
|
|
}
|
|
for k, v := range defaults.Requests {
|
|
if _, exists := merged.Requests[k]; !exists {
|
|
merged.Requests[k] = v
|
|
}
|
|
}
|
|
if merged.Limits == nil {
|
|
merged.Limits = corev1.ResourceList{}
|
|
}
|
|
for k, v := range defaults.Limits {
|
|
if _, exists := merged.Limits[k]; !exists {
|
|
merged.Limits[k] = v
|
|
}
|
|
}
|
|
return *merged
|
|
}
|
|
|
|
// reconcileResources handles the creation of all Kubernetes resources for the LlamaDeployment.
|
|
// buildId is the resolved build artifact ID (empty string if no build was needed).
|
|
func (r *LlamaDeploymentReconciler) reconcileResources(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment, buildId string) error {
|
|
// Apply ServiceAccount via SSA
|
|
sa := &corev1.ServiceAccount{
|
|
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ServiceAccount"},
|
|
ObjectMeta: metav1.ObjectMeta{Name: llamaDeploy.Name + "-sa", Namespace: llamaDeploy.Namespace},
|
|
AutomountServiceAccountToken: ptr(false),
|
|
}
|
|
if err := controllerutil.SetControllerReference(llamaDeploy, sa, r.Scheme); err != nil {
|
|
return err
|
|
}
|
|
saPatchOpts := []client.PatchOption{client.FieldOwner("llama-deploy-operator")}
|
|
if r.shouldForceOwnership(llamaDeploy) {
|
|
saPatchOpts = append(saPatchOpts, client.ForceOwnership)
|
|
}
|
|
if err := r.Patch(ctx, sa, client.Apply, saPatchOpts...); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Verify secret exists if specified
|
|
if llamaDeploy.Spec.SecretName != "" {
|
|
if err := r.verifySecret(ctx, llamaDeploy); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// CreateOrUpdate ConfigMap for nginx configuration
|
|
if err := r.reconcileNginxConfigMap(ctx, llamaDeploy); err != nil {
|
|
return err
|
|
}
|
|
|
|
// CreateOrUpdate Deployment
|
|
if err := r.reconcileDeployment(ctx, llamaDeploy, buildId); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create Service
|
|
if err := r.reconcileService(ctx, llamaDeploy); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// verifySecret ensures the Secret exists but does NOT create it
|
|
// Secrets should be created by the API server or user before creating LlamaDeployment
|
|
func (r *LlamaDeploymentReconciler) verifySecret(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment) error {
|
|
found := &corev1.Secret{}
|
|
err := r.Get(ctx, client.ObjectKey{Name: llamaDeploy.Spec.SecretName, Namespace: llamaDeploy.Namespace}, found)
|
|
if err != nil && errors.IsNotFound(err) {
|
|
return fmt.Errorf("secret %s not found - secrets must be created separately before creating LlamaDeployment", llamaDeploy.Spec.SecretName)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// reconcileDeployment creates or updates the Deployment for the LlamaDeployment.
|
|
// buildId is the resolved build artifact ID passed explicitly rather than read from Status.
|
|
func (r *LlamaDeploymentReconciler) reconcileDeployment(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment, buildId string) error {
|
|
logger := log.FromContext(ctx)
|
|
|
|
// Unpause the Deployment if it was paused by a previous timeout remediation,
|
|
// but ONLY when the spec generation has advanced past the failed one.
|
|
// We require FailedRolloutGeneration > 0 to guard against a stale informer
|
|
// cache: a racing reconcile triggered by the Deployment-pause event may read
|
|
// the LlamaDeployment before the timeout handler's status update (which sets
|
|
// FailedRolloutGeneration) has landed. In that case FailedRolloutGeneration
|
|
// is still 0 and we must not unpause, otherwise the Kubernetes Deployment
|
|
// controller scales the failing ReplicaSet back up.
|
|
existing := &appsv1.Deployment{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: llamaDeploy.Name, Namespace: llamaDeploy.Namespace}, existing); err == nil {
|
|
if existing.Spec.Paused &&
|
|
llamaDeploy.Status.FailedRolloutGeneration > 0 &&
|
|
llamaDeploy.Generation > llamaDeploy.Status.FailedRolloutGeneration {
|
|
patch := client.MergeFrom(existing.DeepCopy())
|
|
existing.Spec.Paused = false
|
|
if patchErr := r.Patch(ctx, existing, patch); patchErr != nil {
|
|
logger.Error(patchErr, "Failed to unpause deployment")
|
|
return patchErr
|
|
}
|
|
logger.Info("Unpaused Deployment for new rollout")
|
|
}
|
|
}
|
|
|
|
desired := r.createDeploymentForLlama(llamaDeploy, buildId)
|
|
|
|
// Apply LlamaDeploymentTemplate via strategic merge, template takes precedence
|
|
if err := r.applyTemplateOverlay(ctx, llamaDeploy, desired); err != nil {
|
|
return err
|
|
}
|
|
|
|
dep := &appsv1.Deployment{
|
|
TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: llamaDeploy.Name,
|
|
Namespace: llamaDeploy.Namespace,
|
|
},
|
|
Spec: desired.Spec,
|
|
}
|
|
if err := controllerutil.SetControllerReference(llamaDeploy, dep, r.Scheme); err != nil {
|
|
return err
|
|
}
|
|
depPatchOpts := []client.PatchOption{client.FieldOwner("llama-deploy-operator")}
|
|
if r.shouldForceOwnership(llamaDeploy) {
|
|
depPatchOpts = append(depPatchOpts, client.ForceOwnership)
|
|
}
|
|
if err := r.Patch(ctx, dep, client.Apply, depPatchOpts...); err != nil {
|
|
return err
|
|
}
|
|
logger.V(1).Info("Reconciled Deployment", "name", llamaDeploy.Name)
|
|
return nil
|
|
}
|
|
|
|
// reconcileNginxConfigMap creates or updates the ConfigMap containing nginx configuration
|
|
func (r *LlamaDeploymentReconciler) reconcileNginxConfigMap(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment) error {
|
|
cm := &corev1.ConfigMap{
|
|
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "ConfigMap"},
|
|
ObjectMeta: metav1.ObjectMeta{Name: llamaDeploy.Name + "-nginx-config", Namespace: llamaDeploy.Namespace},
|
|
Data: map[string]string{"nginx.conf": r.generateNginxConfig(llamaDeploy)},
|
|
}
|
|
if err := controllerutil.SetControllerReference(llamaDeploy, cm, r.Scheme); err != nil {
|
|
return err
|
|
}
|
|
cmPatchOpts := []client.PatchOption{client.FieldOwner("llama-deploy-operator")}
|
|
if r.shouldForceOwnership(llamaDeploy) {
|
|
cmPatchOpts = append(cmPatchOpts, client.ForceOwnership)
|
|
}
|
|
return r.Patch(ctx, cm, client.Apply, cmPatchOpts...)
|
|
}
|
|
|
|
// reconcileService creates or updates the Service for the LlamaDeployment
|
|
func (r *LlamaDeploymentReconciler) reconcileService(ctx context.Context, llamaDeploy *llamadeployv1.LlamaDeployment) error {
|
|
svc := &corev1.Service{
|
|
TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: llamaDeploy.Name,
|
|
Namespace: llamaDeploy.Namespace,
|
|
Labels: map[string]string{
|
|
"app": llamaDeploy.Name,
|
|
"app.kubernetes.io/managed-by": "llama-deploy-operator",
|
|
"component": "appserver",
|
|
},
|
|
},
|
|
Spec: corev1.ServiceSpec{
|
|
Selector: map[string]string{"app": llamaDeploy.Name},
|
|
Type: corev1.ServiceTypeClusterIP,
|
|
Ports: []corev1.ServicePort{
|
|
{
|
|
Protocol: corev1.ProtocolTCP,
|
|
Name: "http",
|
|
Port: 80,
|
|
TargetPort: intstr.FromInt(8081),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
if err := controllerutil.SetControllerReference(llamaDeploy, svc, r.Scheme); err != nil {
|
|
return err
|
|
}
|
|
svcPatchOpts := []client.PatchOption{client.FieldOwner("llama-deploy-operator")}
|
|
if r.shouldForceOwnership(llamaDeploy) {
|
|
svcPatchOpts = append(svcPatchOpts, client.ForceOwnership)
|
|
}
|
|
return r.Patch(ctx, svc, client.Apply, svcPatchOpts...)
|
|
}
|
|
|
|
// commonEnvVars returns the environment variables shared by deployment pods and build jobs.
|
|
func (r *LlamaDeploymentReconciler) commonEnvVars(llamaDeploy *llamadeployv1.LlamaDeployment) []corev1.EnvVar {
|
|
deploymentFilePath := llamaDeploy.Spec.DeploymentFilePath
|
|
if deploymentFilePath == "" {
|
|
deploymentFilePath = "."
|
|
}
|
|
|
|
// Build API base URL for git proxy
|
|
buildAPIHost := os.Getenv("LLAMA_DEPLOY_BUILD_API_HOST")
|
|
if buildAPIHost == "" {
|
|
buildAPIHost = "llama-agents-build.llama-agents.svc.cluster.local:8001"
|
|
}
|
|
|
|
// Build authenticated repo URL with embedded token
|
|
repoURL := fmt.Sprintf("http://%s/deployments/%s", buildAPIHost, llamaDeploy.Name)
|
|
|
|
envVars := []corev1.EnvVar{
|
|
// used in llama_deploy/docker/bootstrap.sh to determine the repo to clone via build API
|
|
{Name: "LLAMA_DEPLOY_REPO_URL", Value: repoURL},
|
|
// used in llama_deploy/docker/bootstrap.sh to determine the git ref to clone via build API
|
|
{Name: "LLAMA_DEPLOY_GIT_REF", Value: llamaDeploy.Spec.GitRef},
|
|
// used in llama_deploy/docker/bootstrap.sh to determine the git sha to clone via build API
|
|
{Name: "LLAMA_DEPLOY_GIT_SHA", Value: llamaDeploy.Spec.GitSha},
|
|
// used in llama_deploy/docker/bootstrap.sh to determine the config to autodeploy
|
|
{Name: "LLAMA_DEPLOY_DEPLOYMENT_FILE_PATH", Value: deploymentFilePath},
|
|
// used in llama_cloud_services/llama_cloud_services/beta/agent_data/client.py to infer an agent environment
|
|
{Name: "LLAMA_DEPLOY_DEPLOYMENT_NAME", Value: llamaDeploy.Name},
|
|
// Auth token for accessing build control plane API
|
|
{Name: "LLAMA_DEPLOY_AUTH_TOKEN", Value: llamaDeploy.Status.AuthToken},
|
|
// Build API service address for git proxy
|
|
{Name: "LLAMA_DEPLOY_BUILD_API_HOST", Value: buildAPIHost},
|
|
// the project ID
|
|
{Name: "LLAMA_DEPLOY_PROJECT_ID", Value: llamaDeploy.Spec.ProjectId},
|
|
// used in llama_deploy/apiserver/deployment.py to determine if the deployment is running locally or in a deployed environment
|
|
{Name: "LLAMA_DEPLOY_IS_DEPLOYED", Value: "true"},
|
|
// configure structured JSON logging for the appserver
|
|
{Name: "LOG_FORMAT", Value: "json"},
|
|
// Trust /opt/app regardless of ownership so git operations work when
|
|
// the container uid differs from the EmptyDir volume owner.
|
|
{Name: "GIT_CONFIG_COUNT", Value: "1"},
|
|
{Name: "GIT_CONFIG_KEY_0", Value: "safe.directory"},
|
|
{Name: "GIT_CONFIG_VALUE_0", Value: "/opt/app"},
|
|
}
|
|
|
|
// If the deployment is pinned to a specific appserver version via imageTag,
|
|
// pass that version to the build Job / init container so it installs the
|
|
// correct appserver instead of the one bundled in the image.
|
|
if llamaDeploy.Spec.ImageTag != "" {
|
|
envVars = append(envVars, corev1.EnvVar{
|
|
Name: "LLAMA_DEPLOY_APPSERVER_VERSION",
|
|
Value: getContainerImageTag(llamaDeploy),
|
|
})
|
|
}
|
|
|
|
return envVars
|
|
}
|
|
|
|
// commonEnvFrom returns the envFrom sources shared by deployment pods and build jobs.
|
|
func (r *LlamaDeploymentReconciler) commonEnvFrom(llamaDeploy *llamadeployv1.LlamaDeployment) []corev1.EnvFromSource {
|
|
if llamaDeploy.Spec.SecretName != "" {
|
|
return []corev1.EnvFromSource{
|
|
{
|
|
SecretRef: &corev1.SecretEnvSource{
|
|
LocalObjectReference: corev1.LocalObjectReference{
|
|
Name: llamaDeploy.Spec.SecretName,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createDeploymentForLlama creates a Deployment object for the LlamaDeployment.
|
|
// buildId is passed explicitly rather than read from Status.BuildId.
|
|
func (r *LlamaDeploymentReconciler) createDeploymentForLlama(llamaDeploy *llamadeployv1.LlamaDeployment, buildId string) *appsv1.Deployment {
|
|
deploymentFilePath := llamaDeploy.Spec.DeploymentFilePath
|
|
if deploymentFilePath == "" {
|
|
deploymentFilePath = "."
|
|
}
|
|
|
|
// Compute container working directory based on deployment file path
|
|
workingDir := "/opt/app"
|
|
if deploymentFilePath != "." {
|
|
normalized := strings.TrimPrefix(deploymentFilePath, "./")
|
|
normalized = strings.TrimPrefix(normalized, "/")
|
|
resolved := normalized
|
|
if looksLikeFilePath(normalized) {
|
|
resolved = path.Dir(normalized)
|
|
if resolved == "." || resolved == "/" {
|
|
resolved = ""
|
|
}
|
|
}
|
|
if resolved != "" {
|
|
workingDir = "/opt/app/" + resolved
|
|
}
|
|
}
|
|
|
|
// Build environment variables from common helper
|
|
envVars := r.commonEnvVars(llamaDeploy)
|
|
|
|
// If a build artifact exists, tell the init container to download it instead of building
|
|
if buildId != "" {
|
|
envVars = append(envVars, corev1.EnvVar{
|
|
Name: "LLAMA_DEPLOY_BUILD_ID",
|
|
Value: buildId,
|
|
})
|
|
}
|
|
|
|
envFrom := r.commonEnvFrom(llamaDeploy)
|
|
|
|
// Prepare pod template annotations
|
|
podAnnotations := map[string]string{}
|
|
|
|
// Add git source annotation to trigger redeployment when repo or ref changes
|
|
gitSource := llamaDeploy.Spec.RepoUrl
|
|
if llamaDeploy.Spec.GitSha != "" || llamaDeploy.Spec.GitRef != "" {
|
|
gitSource = fmt.Sprintf("%s@%s", llamaDeploy.Spec.RepoUrl, llamaDeploy.Spec.GitSha)
|
|
}
|
|
podAnnotations["deploy.llamaindex.ai/git-source"] = gitSource
|
|
|
|
// Add secret hash annotation if present on the LlamaDeployment
|
|
if llamaDeploy.Annotations != nil {
|
|
if secretHash, exists := llamaDeploy.Annotations["deploy.llamaindex.ai/secret-hash"]; exists {
|
|
podAnnotations["deploy.llamaindex.ai/secret-hash"] = secretHash
|
|
}
|
|
}
|
|
|
|
replicas := int32(1)
|
|
if llamaDeploy.Spec.Suspended ||
|
|
(llamaDeploy.Status.FailedRolloutGeneration == llamaDeploy.Generation &&
|
|
llamaDeploy.Status.Phase == PhaseFailed) {
|
|
replicas = int32(0)
|
|
}
|
|
return &appsv1.Deployment{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: llamaDeploy.Name,
|
|
Namespace: llamaDeploy.Namespace,
|
|
},
|
|
Spec: appsv1.DeploymentSpec{
|
|
Replicas: &replicas,
|
|
ProgressDeadlineSeconds: getRolloutTimeoutSeconds(),
|
|
RevisionHistoryLimit: ptr(DeploymentRevisionHistoryLimit),
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{"app": llamaDeploy.Name},
|
|
},
|
|
Template: corev1.PodTemplateSpec{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Labels: map[string]string{
|
|
"app": llamaDeploy.Name,
|
|
"app.kubernetes.io/managed-by": "llama-deploy-operator",
|
|
},
|
|
Annotations: podAnnotations,
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
SecurityContext: defaultPodSecurityContext(),
|
|
ServiceAccountName: llamaDeploy.Name + "-sa",
|
|
AutomountServiceAccountToken: ptr(false),
|
|
Volumes: []corev1.Volume{
|
|
{
|
|
Name: "app-data",
|
|
VolumeSource: corev1.VolumeSource{
|
|
EmptyDir: &corev1.EmptyDirVolumeSource{},
|
|
},
|
|
},
|
|
{
|
|
Name: "nginx-config",
|
|
VolumeSource: corev1.VolumeSource{
|
|
ConfigMap: &corev1.ConfigMapVolumeSource{
|
|
LocalObjectReference: corev1.LocalObjectReference{
|
|
Name: llamaDeploy.Name + "-nginx-config",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
InitContainers: []corev1.Container{
|
|
{
|
|
Name: "bootstrap",
|
|
Image: fmt.Sprintf("%s:%s", getDefaultImage(), getDefaultImageTag()),
|
|
ImagePullPolicy: getContainerImagePullPolicy(),
|
|
Env: envVars,
|
|
EnvFrom: envFrom,
|
|
Command: []string{"python", "-m", "llama_deploy.appserver.bootstrap"},
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{Name: "app-data", MountPath: "/opt/app"},
|
|
},
|
|
SecurityContext: defaultContainerSecurityContext(),
|
|
},
|
|
},
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "file-server",
|
|
Image: fmt.Sprintf("%s:%s", getNginxImage(), getNginxImageTag()),
|
|
ImagePullPolicy: getNginxImagePullPolicy(),
|
|
Ports: []corev1.ContainerPort{{ContainerPort: 8081, Protocol: corev1.ProtocolTCP}},
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{Name: "app-data", MountPath: "/opt/app"},
|
|
{Name: "nginx-config", MountPath: "/etc/nginx/nginx.conf", SubPath: "nginx.conf"},
|
|
},
|
|
Command: []string{"nginx", "-g", "daemon off;"},
|
|
SecurityContext: &corev1.SecurityContext{
|
|
RunAsNonRoot: ptr(true),
|
|
RunAsUser: ptr(NginxUID),
|
|
RunAsGroup: ptr(NginxGID),
|
|
AllowPrivilegeEscalation: ptr(false),
|
|
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
|
},
|
|
},
|
|
{
|
|
Name: ContainerNameApp,
|
|
Image: fmt.Sprintf("%s:%s", getContainerImage(llamaDeploy), getContainerImageTag(llamaDeploy)),
|
|
ImagePullPolicy: getContainerImagePullPolicy(),
|
|
WorkingDir: workingDir,
|
|
Command: []string{"uv", "run", "--no-sync", "python", "-m", "llama_deploy.appserver.app"},
|
|
Env: envVars,
|
|
EnvFrom: envFrom,
|
|
VolumeMounts: []corev1.VolumeMount{{Name: "app-data", MountPath: "/opt/app"}},
|
|
Ports: []corev1.ContainerPort{{ContainerPort: 8080, Protocol: corev1.ProtocolTCP}},
|
|
StartupProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/health", Port: intstr.FromInt(8080)}}, PeriodSeconds: 5, FailureThreshold: 24},
|
|
LivenessProbe: &corev1.Probe{ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/health", Port: intstr.FromInt(8080)}}, PeriodSeconds: 5, FailureThreshold: 12},
|
|
Resources: corev1.ResourceRequirements{Requests: getDefaultResourceRequests(), Limits: getDefaultResourceLimits()},
|
|
SecurityContext: hardenedSecurityContext(),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// generateNginxConfig builds the nginx.conf content for the deployment
|
|
func (r *LlamaDeploymentReconciler) generateNginxConfig(llamaDeploy *llamadeployv1.LlamaDeployment) string {
|
|
basePath := fmt.Sprintf("/deployments/%s/ui", llamaDeploy.GetObjectMeta().GetName())
|
|
assetsPath := llamaDeploy.Spec.StaticAssetsPath
|
|
var staticLocation string
|
|
if assetsPath != "" {
|
|
// Serve static files from /opt/app/<assetsPath>, fallback to python for misses
|
|
staticLocation = fmt.Sprintf("location %s { alias /opt/app/%s/; try_files $uri $uri/ /index.html @python_upstream; }", basePath, assetsPath)
|
|
} else {
|
|
// If not provided, proxy UI base to python
|
|
staticLocation = fmt.Sprintf("location %s { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }", basePath)
|
|
}
|
|
|
|
// Everything else proxies to python on 8081; also define named upstream for try_files fallback
|
|
proxyLocation := "location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }\nlocation @python_upstream { proxy_pass http://127.0.0.1:8081; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }"
|
|
|
|
return fmt.Sprintf(`pid /tmp/nginx.pid;
|
|
worker_processes 1;
|
|
events { worker_connections 1024; }
|
|
http {
|
|
client_body_temp_path /tmp/client_temp;
|
|
proxy_temp_path /tmp/proxy_temp;
|
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
|
scgi_temp_path /tmp/scgi_temp;
|
|
include mime.types;
|
|
default_type application/octet-stream;
|
|
sendfile on;
|
|
keepalive_timeout 65;
|
|
access_log off;
|
|
server {
|
|
# Ensure redirects are relative (do not include scheme/host)
|
|
absolute_redirect off;
|
|
listen 8081;
|
|
%s
|
|
%s
|
|
}
|
|
}`, staticLocation, proxyLocation)
|
|
}
|
|
|
|
// shouldForceOwnership returns true when the controller should force SSA field ownership
|
|
// during migration windows, i.e., when the controller's static schema version is
|
|
// greater than the resource's current status schema version.
|
|
func (r *LlamaDeploymentReconciler) shouldForceOwnership(ld *llamadeployv1.LlamaDeployment) bool {
|
|
// Force when CR spec changed since last successful reconcile
|
|
if ld.Status.LastReconciledGeneration != ld.Generation {
|
|
return true
|
|
}
|
|
// Explicit override via annotation
|
|
if ld.Annotations != nil {
|
|
if ld.Annotations["deploy.llamaindex.ai/force-ownership"] == "true" {
|
|
return true
|
|
}
|
|
}
|
|
// Treat empty status schema version as -1 (always migrate up)
|
|
parse := func(s string) (int, bool) {
|
|
if s == "" {
|
|
return -1, true
|
|
}
|
|
n := 0
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c < '0' || c > '9' {
|
|
return 0, false
|
|
}
|
|
n = n*10 + int(c-'0')
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
cur, okCur := parse(CurrentSchemaVersion)
|
|
prev, okPrev := parse(ld.Status.SchemaVersion)
|
|
if okCur && okPrev {
|
|
return cur > prev
|
|
}
|
|
// Fallback to lexicographic comparison if parsing fails
|
|
return CurrentSchemaVersion > ld.Status.SchemaVersion
|
|
}
|
|
|
|
// applyTemplateOverlay merges a referenced LlamaDeploymentTemplate into the desired Deployment's PodTemplate.
|
|
// The template's values take precedence over operator defaults.
|
|
func (r *LlamaDeploymentReconciler) applyTemplateOverlay(ctx context.Context, ld *llamadeployv1.LlamaDeployment, dep *appsv1.Deployment) error {
|
|
// Determine template name: spec.templateName or "default"
|
|
templateName := ld.Spec.TemplateName
|
|
if templateName == "" {
|
|
templateName = "default"
|
|
}
|
|
|
|
// Try to fetch template in the same namespace
|
|
tmpl := &llamadeployv1.LlamaDeploymentTemplate{}
|
|
if err := r.Get(ctx, client.ObjectKey{Name: templateName, Namespace: ld.Namespace}, tmpl); err != nil {
|
|
if errors.IsNotFound(err) {
|
|
return nil // no template; nothing to merge
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Strategic merge allows specifying containers by name to override specific fields.
|
|
// We don't need to validate this here - the merge will fail naturally if something is wrong.
|
|
|
|
// Use Kubernetes strategic merge patch semantics to merge overlay into the desired PodTemplateSpec.
|
|
// This respects list merge keys (e.g., containers by name) and map merges (labels/annotations).
|
|
base := dep.Spec.Template
|
|
patch := tmpl.Spec.PodSpec
|
|
|
|
baseJSON, err := json.Marshal(base)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal base template: %w", err)
|
|
}
|
|
patchJSON, err := json.Marshal(patch)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal overlay template: %w", err)
|
|
}
|
|
|
|
// Clean up the patch to remove empty/nil fields that would override base values
|
|
var patchMap map[string]interface{}
|
|
if err := json.Unmarshal(patchJSON, &patchMap); err != nil {
|
|
return fmt.Errorf("unmarshal patch for cleanup: %w", err)
|
|
}
|
|
|
|
// Remove metadata.creationTimestamp which can appear in serialized objects
|
|
if metadata, ok := patchMap["metadata"].(map[string]interface{}); ok {
|
|
delete(metadata, "creationTimestamp")
|
|
if len(metadata) == 0 {
|
|
delete(patchMap, "metadata")
|
|
}
|
|
}
|
|
|
|
// Clean empty arrays and nil values from spec to avoid clearing base values
|
|
if spec, ok := patchMap["spec"].(map[string]interface{}); ok {
|
|
// Remove nil or empty container-related arrays
|
|
for _, field := range []string{"containers", "initContainers", "volumes", "imagePullSecrets", "hostAliases"} {
|
|
if val := spec[field]; val == nil {
|
|
delete(spec, field)
|
|
} else if arr, ok := val.([]interface{}); ok && len(arr) == 0 {
|
|
delete(spec, field)
|
|
}
|
|
}
|
|
// Remove nil or empty maps
|
|
for _, field := range []string{"nodeSelector", "securityContext", "affinity"} {
|
|
if val := spec[field]; val == nil {
|
|
delete(spec, field)
|
|
} else if m, ok := val.(map[string]interface{}); ok && len(m) == 0 {
|
|
delete(spec, field)
|
|
}
|
|
}
|
|
|
|
// Strip "build" containers from the runtime overlay. The "build" container
|
|
// is only meaningful for build jobs (handled by applyBuildJobTemplateOverlay).
|
|
// Strategic merge would add it as a new container to the runtime Deployment
|
|
// with no image or command, producing an invalid pod spec.
|
|
if containers, ok := spec["containers"].([]interface{}); ok {
|
|
filtered := make([]interface{}, 0, len(containers))
|
|
for _, c := range containers {
|
|
if cm, ok := c.(map[string]interface{}); ok {
|
|
if cm["name"] == ContainerNameBuild {
|
|
continue
|
|
}
|
|
}
|
|
filtered = append(filtered, c)
|
|
}
|
|
if len(filtered) == 0 {
|
|
delete(spec, "containers")
|
|
} else {
|
|
spec["containers"] = filtered
|
|
}
|
|
}
|
|
}
|
|
|
|
patchJSON, err = json.Marshal(patchMap)
|
|
if err != nil {
|
|
return fmt.Errorf("remarshal cleaned patch: %w", err)
|
|
}
|
|
|
|
mergedJSON, err := strategicpatch.StrategicMergePatch(baseJSON, patchJSON, corev1.PodTemplateSpec{})
|
|
if err != nil {
|
|
return fmt.Errorf("strategic merge template: %w", err)
|
|
}
|
|
var merged corev1.PodTemplateSpec
|
|
if err := json.Unmarshal(mergedJSON, &merged); err != nil {
|
|
return fmt.Errorf("unmarshal merged template: %w", err)
|
|
}
|
|
|
|
dep.Spec.Template = merged
|
|
return nil
|
|
}
|