diff --git a/components/operator/internal/handlers/sessions.go b/components/operator/internal/handlers/sessions.go index 05926c523..7d4795141 100755 --- a/components/operator/internal/handlers/sessions.go +++ b/components/operator/internal/handlers/sessions.go @@ -25,6 +25,7 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" intstr "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/util/retry" ) @@ -1453,6 +1454,9 @@ func handleAgenticSessionEvent(obj *unstructured.Unstructured) error { } } + // Mount trusted CA bundle if present in the session namespace (e.g. OpenShift CA injection) + applyTrustedCABundle(config.K8sClient, sessionNamespace, pod) + // NOTE: Google credentials are now fetched at runtime via backend API // No longer mounting credentials.json as volume // This ensures tokens are always fresh and automatically refreshed @@ -2391,6 +2395,55 @@ func deleteAmbientMlflowObservabilitySecret(ctx context.Context, namespace, excl return nil } +// applyTrustedCABundle mounts the cluster's injected CA bundle into the runner +// container so it trusts cluster-internal TLS certificates (e.g. on OpenShift). +// Clusters without the ConfigMap are silently unaffected. +func applyTrustedCABundle(k8sClient kubernetes.Interface, namespace string, pod *corev1.Pod) { + cm, err := k8sClient.CoreV1().ConfigMaps(namespace).Get( + context.TODO(), types.TrustedCABundleConfigMapName, v1.GetOptions{}) + if errors.IsNotFound(err) { + return + } + if err != nil { + log.Printf("Warning: failed to check for %s ConfigMap in %s: %v", + types.TrustedCABundleConfigMapName, namespace, err) + return + } + if _, ok := cm.Data["ca-bundle.crt"]; !ok { + if _, ok := cm.BinaryData["ca-bundle.crt"]; !ok { + log.Printf("Warning: %s ConfigMap in %s is missing required key ca-bundle.crt; skipping mount", + types.TrustedCABundleConfigMapName, namespace) + return + } + } + pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{ + Name: "trusted-ca-bundle", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: types.TrustedCABundleConfigMapName, + }, + }, + }, + }) + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == "ambient-code-runner" { + pod.Spec.Containers[i].VolumeMounts = append( + pod.Spec.Containers[i].VolumeMounts, + corev1.VolumeMount{ + Name: "trusted-ca-bundle", + MountPath: "/etc/pki/tls/certs/ca-bundle.crt", + SubPath: "ca-bundle.crt", + ReadOnly: true, + }, + ) + log.Printf("Mounted %s ConfigMap to /etc/pki/tls/certs/ca-bundle.crt in runner container", + types.TrustedCABundleConfigMapName) + break + } + } +} + // LEGACY: getBackendAPIURL removed - AG-UI migration // Workflow and repo changes now call runner's REST endpoints directly diff --git a/components/operator/internal/handlers/sessions_test.go b/components/operator/internal/handlers/sessions_test.go index 6df9750c3..223286351 100644 --- a/components/operator/internal/handlers/sessions_test.go +++ b/components/operator/internal/handlers/sessions_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "testing" "ambient-code-operator/internal/config" @@ -13,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" + clienttesting "k8s.io/client-go/testing" ) // setupTestClient initializes a fake Kubernetes client for testing @@ -568,6 +570,193 @@ func TestDeleteAmbientVertexSecret_NotFound(t *testing.T) { } } +// TestApplyTrustedCABundle_ConfigMapPresent verifies that applyTrustedCABundle adds the volume +// and VolumeMount when the trusted-ca-bundle ConfigMap exists in the session namespace. +func TestApplyTrustedCABundle_ConfigMapPresent(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: types.TrustedCABundleConfigMapName, + Namespace: "session-ns", + }, + Data: map[string]string{ + "ca-bundle.crt": "--- fake CA data ---", + }, + } + setupTestClient(cm) + + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "ambient-code-runner"}, + }, + }, + } + + applyTrustedCABundle(config.K8sClient, "session-ns", pod) + + if len(pod.Spec.Volumes) != 1 { + t.Fatalf("expected 1 volume, got %d", len(pod.Spec.Volumes)) + } + vol := pod.Spec.Volumes[0] + if vol.Name != "trusted-ca-bundle" { + t.Errorf("expected volume name 'trusted-ca-bundle', got %q", vol.Name) + } + if vol.ConfigMap == nil || vol.ConfigMap.Name != types.TrustedCABundleConfigMapName { + t.Errorf("expected ConfigMap volume sourced from %q", types.TrustedCABundleConfigMapName) + } + + mounts := pod.Spec.Containers[0].VolumeMounts + if len(mounts) != 1 { + t.Fatalf("expected 1 VolumeMount, got %d", len(mounts)) + } + m := mounts[0] + if m.Name != "trusted-ca-bundle" { + t.Errorf("expected mount name 'trusted-ca-bundle', got %q", m.Name) + } + if m.MountPath != "/etc/pki/tls/certs/ca-bundle.crt" { + t.Errorf("unexpected MountPath: %q", m.MountPath) + } + if m.SubPath != "ca-bundle.crt" { + t.Errorf("expected SubPath 'ca-bundle.crt', got %q", m.SubPath) + } + if !m.ReadOnly { + t.Error("expected ReadOnly=true") + } +} + +// TestApplyTrustedCABundle_ConfigMapAbsent verifies that applyTrustedCABundle leaves the pod +// unchanged when the trusted-ca-bundle ConfigMap is not present in the session namespace. +func TestApplyTrustedCABundle_ConfigMapAbsent(t *testing.T) { + setupTestClient() // no ConfigMap + + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "ambient-code-runner"}, + }, + }, + } + + applyTrustedCABundle(config.K8sClient, "session-ns", pod) + + if len(pod.Spec.Volumes) != 0 { + t.Errorf("expected no volumes, got %d", len(pod.Spec.Volumes)) + } + if len(pod.Spec.Containers[0].VolumeMounts) != 0 { + t.Errorf("expected no VolumeMounts, got %d", len(pod.Spec.Containers[0].VolumeMounts)) + } +} + +// TestApplyTrustedCABundle_ExistingMountsPreserved verifies that applyTrustedCABundle appends +// to, rather than replacing, existing VolumeMounts on the runner container. +func TestApplyTrustedCABundle_ExistingMountsPreserved(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: types.TrustedCABundleConfigMapName, + Namespace: "session-ns", + }, + Data: map[string]string{ + "ca-bundle.crt": "--- fake CA data ---", + }, + } + setupTestClient(cm) + + existingMount := corev1.VolumeMount{ + Name: "runner-token", + MountPath: "/var/run/secrets/ambient", + ReadOnly: true, + } + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + {Name: "runner-token"}, + }, + Containers: []corev1.Container{ + { + Name: "ambient-code-runner", + VolumeMounts: []corev1.VolumeMount{existingMount}, + }, + }, + }, + } + + applyTrustedCABundle(config.K8sClient, "session-ns", pod) + + if len(pod.Spec.Volumes) != 2 { + t.Fatalf("expected 2 volumes (runner-token + trusted-ca-bundle), got %d", len(pod.Spec.Volumes)) + } + mounts := pod.Spec.Containers[0].VolumeMounts + if len(mounts) != 2 { + t.Fatalf("expected 2 VolumeMounts, got %d", len(mounts)) + } + // Existing mount must still be at index 0 + if mounts[0].Name != "runner-token" { + t.Errorf("expected first mount to be 'runner-token', got %q", mounts[0].Name) + } + if mounts[1].Name != "trusted-ca-bundle" { + t.Errorf("expected second mount to be 'trusted-ca-bundle', got %q", mounts[1].Name) + } +} + +// TestApplyTrustedCABundle_MissingKey verifies that applyTrustedCABundle leaves the pod +// unchanged when the ConfigMap exists but lacks the ca-bundle.crt key. +func TestApplyTrustedCABundle_MissingKey(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: types.TrustedCABundleConfigMapName, + Namespace: "session-ns", + }, + Data: map[string]string{ + "wrong-key.pem": "--- fake CA data ---", + }, + } + setupTestClient(cm) + + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "ambient-code-runner"}, + }, + }, + } + + applyTrustedCABundle(config.K8sClient, "session-ns", pod) + + if len(pod.Spec.Volumes) != 0 { + t.Errorf("expected no volumes when key is missing, got %d", len(pod.Spec.Volumes)) + } + if len(pod.Spec.Containers[0].VolumeMounts) != 0 { + t.Errorf("expected no VolumeMounts when key is missing, got %d", len(pod.Spec.Containers[0].VolumeMounts)) + } +} + +// TestApplyTrustedCABundle_APIError verifies that applyTrustedCABundle leaves the pod +// unchanged when the ConfigMap GET returns a non-NotFound error. +func TestApplyTrustedCABundle_APIError(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + fakeClient.PrependReactor("get", "configmaps", func(action clienttesting.Action) (bool, runtime.Object, error) { + return true, nil, fmt.Errorf("connection refused") + }) + config.K8sClient = fakeClient + + pod := &corev1.Pod{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "ambient-code-runner"}, + }, + }, + } + + applyTrustedCABundle(config.K8sClient, "session-ns", pod) + + if len(pod.Spec.Volumes) != 0 { + t.Errorf("expected no volumes on API error, got %d", len(pod.Spec.Volumes)) + } + if len(pod.Spec.Containers[0].VolumeMounts) != 0 { + t.Errorf("expected no VolumeMounts on API error, got %d", len(pod.Spec.Containers[0].VolumeMounts)) + } +} + // TestDeleteAmbientVertexSecret_NilAnnotations tests handling of secret with nil annotations func TestDeleteAmbientVertexSecret_NilAnnotations(t *testing.T) { secret := &corev1.Secret{ diff --git a/components/operator/internal/types/resources.go b/components/operator/internal/types/resources.go index 59d0906ca..af6af1343 100644 --- a/components/operator/internal/types/resources.go +++ b/components/operator/internal/types/resources.go @@ -7,6 +7,10 @@ const ( // AmbientVertexSecretName is the name of the secret containing Vertex AI credentials AmbientVertexSecretName = "ambient-vertex" + // TrustedCABundleConfigMapName is the CA bundle ConfigMap injected by OpenShift or provisioned + // manually on private-CA clusters. See issue #1247. + TrustedCABundleConfigMapName = "trusted-ca-bundle" + // CopiedFromAnnotation is the annotation key used to track secrets copied by the operator CopiedFromAnnotation = "vteam.ambient-code/copied-from" ) diff --git a/components/public-api/Dockerfile b/components/public-api/Dockerfile index b403501e1..11461ac7b 100755 --- a/components/public-api/Dockerfile +++ b/components/public-api/Dockerfile @@ -7,13 +7,13 @@ USER 0 # Copy go mod files first for caching COPY go.mod go.sum* ./ -RUN go mod download +RUN GOTOOLCHAIN=auto go mod download # Copy source code COPY . . # Build the binary -RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o public-api . +RUN GOTOOLCHAIN=auto CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o public-api . # Runtime stage FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7