From 584b071e35c0b93fbffb26ed8813c5f78354c483 Mon Sep 17 00:00:00 2001 From: hippoley Date: Mon, 27 Apr 2026 16:32:30 +0800 Subject: [PATCH] fix: default imagePullPolicy to IfNotPresent for air-gapped environments Kubernetes defaults imagePullPolicy to Always when the image tag is :latest. This causes pod creation failures in air-gapped and enterprise environments where nodes cannot reach external registries. Changes: - Add ImagePullPolicy field to PodConfig struct - Default to IfNotPresent in CreatePod when not explicitly set - Support IMAGE_PULL_POLICY env var for operator override - Apply policy to both CreateInstance and StartInstance paths Closes #94 --- backend/internal/services/instance_runtime.go | 11 +++++ .../services/instance_runtime_test.go | 42 +++++++++++++++++++ backend/internal/services/instance_service.go | 3 ++ backend/internal/services/k8s/pod_service.go | 14 ++++++- 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 backend/internal/services/instance_runtime_test.go diff --git a/backend/internal/services/instance_runtime.go b/backend/internal/services/instance_runtime.go index 04f151c..eb77a12 100644 --- a/backend/internal/services/instance_runtime.go +++ b/backend/internal/services/instance_runtime.go @@ -142,6 +142,17 @@ func usesWebtopImage(instanceType string) bool { } } +// defaultImagePullPolicy returns the image pull policy to use for instance +// pods. Operators can override the default ("IfNotPresent") by setting the +// IMAGE_PULL_POLICY environment variable to "Always", "Never", or +// "IfNotPresent". +func defaultImagePullPolicy() string { + if v := strings.TrimSpace(os.Getenv("IMAGE_PULL_POLICY")); v != "" { + return v + } + return "IfNotPresent" +} + func defaultEgressProxyURL() (string, bool) { if override := strings.TrimSpace(os.Getenv("CLAWMANAGER_EGRESS_PROXY_URL")); override != "" { return override, true diff --git a/backend/internal/services/instance_runtime_test.go b/backend/internal/services/instance_runtime_test.go new file mode 100644 index 0000000..a28f1bd --- /dev/null +++ b/backend/internal/services/instance_runtime_test.go @@ -0,0 +1,42 @@ +package services + +import "testing" + +func TestDefaultImagePullPolicy_Default(t *testing.T) { + // With no env var set, should return "IfNotPresent". + t.Setenv("IMAGE_PULL_POLICY", "") + got := defaultImagePullPolicy() + if got != "IfNotPresent" { + t.Fatalf("expected IfNotPresent, got %q", got) + } +} + +func TestDefaultImagePullPolicy_EnvOverride(t *testing.T) { + cases := []struct { + env string + want string + }{ + {"Always", "Always"}, + {"Never", "Never"}, + {"IfNotPresent", "IfNotPresent"}, + } + for _, tc := range cases { + t.Run(tc.env, func(t *testing.T) { + t.Setenv("IMAGE_PULL_POLICY", tc.env) + got := defaultImagePullPolicy() + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +} + +func TestDefaultImagePullPolicy_WhitespaceOnly(t *testing.T) { + // Whitespace-only env var should fall back to default. + t.Setenv("IMAGE_PULL_POLICY", " ") + got := defaultImagePullPolicy() + if got != "IfNotPresent" { + t.Fatalf("expected IfNotPresent, got %q", got) + } +} + diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go index b37d5fc..4e9f34e 100644 --- a/backend/internal/services/instance_service.go +++ b/backend/internal/services/instance_service.go @@ -14,6 +14,7 @@ import ( "clawreef/internal/repository" "clawreef/internal/services/k8s" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -313,6 +314,7 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models Image: runtimeConfig.Image, MountPath: runtimeConfig.MountPath, ContainerPort: runtimeConfig.Port, + ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()), ExtraEnv: extraEnv, EnvFromSecretNames: []string{bootstrapSecretName}, } @@ -488,6 +490,7 @@ func (s *instanceService) Start(instanceID int) error { Image: runtimeConfig.Image, MountPath: instance.MountPath, ContainerPort: runtimeConfig.Port, + ImagePullPolicy: corev1.PullPolicy(defaultImagePullPolicy()), ExtraEnv: extraEnv, EnvFromSecretNames: []string{bootstrapSecretName}, } diff --git a/backend/internal/services/k8s/pod_service.go b/backend/internal/services/k8s/pod_service.go index 68d6cbc..153f166 100644 --- a/backend/internal/services/k8s/pod_service.go +++ b/backend/internal/services/k8s/pod_service.go @@ -47,6 +47,7 @@ type PodConfig struct { Image string MountPath string ContainerPort int32 + ImagePullPolicy corev1.PullPolicy ExtraEnv map[string]string EnvFromSecretNames []string } @@ -84,6 +85,14 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P config.ContainerPort = 3001 } + // Default image pull policy to IfNotPresent so that air-gapped and + // enterprise environments can use locally cached images without being + // forced to pull from a remote registry (fixes #94). + pullPolicy := config.ImagePullPolicy + if pullPolicy == "" { + pullPolicy = corev1.PullIfNotPresent + } + pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: podName, @@ -101,8 +110,9 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P RestartPolicy: corev1.RestartPolicyNever, Containers: []corev1.Container{ { - Name: "desktop", - Image: config.Image, + Name: "desktop", + Image: config.Image, + ImagePullPolicy: pullPolicy, Ports: []corev1.ContainerPort{ { ContainerPort: config.ContainerPort,