From b54ae331e73c8aa5fb4c213d6a5b2c40531914a9 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Mon, 15 Jun 2026 19:43:01 +0200 Subject: [PATCH] feat(controller): make Go (ADK) runtime agent image independently configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DefaultGoImageConfig (registry, repository, pullPolicy) with per-field fallback to DefaultImageConfig and the existing golang-adk derivation logic. Exposed as --go-image-{registry,repository,pull-policy} flags (auto-mapped to GO_IMAGE_* env vars via LoadFromEnv) and as controller.goAgentImage.* Helm values. Fixes registry layouts where the Python repository is a flat name that cannot produce a valid path via last-segment replacement (e.g. kagent-golang-adk). Tag is not configurable — Go runtime images are digest-pinned at link time. Closes #2018 Signed-off-by: QuentinBisson --- .../translator/agent/adk_api_translator.go | 7 + .../translator/agent/deployments.go | 37 ++- .../translator/agent/runtime_test.go | 277 ++++++++++++++++++ go/core/pkg/app/app.go | 3 + .../templates/controller-configmap.yaml | 9 + helm/kagent/values.yaml | 10 + 6 files changed, 329 insertions(+), 14 deletions(-) diff --git a/go/core/internal/controller/translator/agent/adk_api_translator.go b/go/core/internal/controller/translator/agent/adk_api_translator.go index 74490cd673..87c9a9b96a 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -122,6 +122,13 @@ var PythonADKImageDigest string var GoADKImageDigest string var GoADKFullImageDigest string +// DefaultGoImageConfig overrides the image used for the Go (ADK) runtime agent. +// Any field left empty falls back to DefaultImageConfig for registry/pullPolicy, or to +// the derived repository (last segment of DefaultImageConfig.Repository replaced with +// "golang-adk") when Repository is empty. Tag is unused — Go runtime images are +// digest-pinned at controller link time via GoADKImageDigest / GoADKFullImageDigest. +var DefaultGoImageConfig = ImageConfig{} + // DefaultSkillsInitImageConfig is the image config for the skills-init container // that clones skill repositories from Git and pulls OCI skill images. var DefaultSkillsInitImageConfig = ImageConfig{ diff --git a/go/core/internal/controller/translator/agent/deployments.go b/go/core/internal/controller/translator/agent/deployments.go index 2a55255697..9b23d600d7 100644 --- a/go/core/internal/controller/translator/agent/deployments.go +++ b/go/core/internal/controller/translator/agent/deployments.go @@ -81,28 +81,24 @@ func getDefaultLabels(agentName string, incoming map[string]string) map[string]s } // getRuntimeImageRepository returns the image repository for a given runtime. -// It respects DefaultImageConfig.Repository for the Python runtime, and derives -// the Go runtime repository by replacing the last path segment with "golang-adk". -// This ensures custom repository configurations (e.g., --image-repository flag) work correctly. +// For the Go runtime: returns DefaultGoImageConfig.Repository when set; otherwise derives +// it from DefaultImageConfig.Repository by replacing the last path segment with "golang-adk". +// For the Python runtime (and default): returns DefaultImageConfig.Repository. func getRuntimeImageRepository(runtime v1alpha2.DeclarativeRuntime) string { switch runtime { case v1alpha2.DeclarativeRuntime_Go: - // Derive Go runtime repository from the default Python repository - // by replacing the last segment (typically "app") with "golang-adk". - // This respects any custom repository configuration. + if DefaultGoImageConfig.Repository != "" { + return DefaultGoImageConfig.Repository + } pythonRepo := DefaultImageConfig.Repository lastSlash := strings.LastIndex(pythonRepo, "/") if lastSlash == -1 { - // No slash found, repository is just the image name return "golang-adk" } - baseRepo := pythonRepo[:lastSlash] - return baseRepo + "/golang-adk" + return pythonRepo[:lastSlash] + "/golang-adk" case v1alpha2.DeclarativeRuntime_Python: - // Use the configured Python repository as-is return DefaultImageConfig.Repository default: - // Default to Python (should never happen due to enum validation) return DefaultImageConfig.Repository } } @@ -175,8 +171,21 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat // Determine runtime (defaults to python if not set; substrate SandboxAgents use Go). runtime := v1alpha2.EffectiveDeclarativeRuntimeForAgent(agent) - // Get registry - registry := DefaultImageConfig.Registry + // Resolve base registry and pull policy; Go runtime uses DefaultGoImageConfig fields + // when set, falling back to DefaultImageConfig for any unset field. + baseRegistry := DefaultImageConfig.Registry + basePullPolicy := DefaultImageConfig.PullPolicy + if runtime == v1alpha2.DeclarativeRuntime_Go { + if DefaultGoImageConfig.Registry != "" { + baseRegistry = DefaultGoImageConfig.Registry + } + if DefaultGoImageConfig.PullPolicy != "" { + basePullPolicy = DefaultGoImageConfig.PullPolicy + } + } + + // Per-agent spec overrides take precedence over all defaults. + registry := baseRegistry if spec.ImageRegistry != "" { registry = spec.ImageRegistry } @@ -198,7 +207,7 @@ func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentDat } } - imagePullPolicy := corev1.PullPolicy(DefaultImageConfig.PullPolicy) + imagePullPolicy := corev1.PullPolicy(basePullPolicy) if spec.ImagePullPolicy != "" { imagePullPolicy = corev1.PullPolicy(spec.ImagePullPolicy) } diff --git a/go/core/internal/controller/translator/agent/runtime_test.go b/go/core/internal/controller/translator/agent/runtime_test.go index f196415ed8..cad05d27ef 100644 --- a/go/core/internal/controller/translator/agent/runtime_test.go +++ b/go/core/internal/controller/translator/agent/runtime_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" schemev1 "k8s.io/client-go/kubernetes/scheme" @@ -487,3 +488,279 @@ func TestRuntime_CustomRepositoryPath_WithSkillsUsesFullTag(t *testing.T) { assert.Contains(t, container.Image, "my-registry.com/custom/golang-adk", "Image should use custom repository with golang-adk") assert.Contains(t, container.Image, "@sha256:test-go-full", "Go runtime with skills should use digest-pinned golang-adk-full image") } + +// withGoImageConfig sets DefaultGoImageConfig for the duration of a test and restores it +// via t.Cleanup. +func withGoImageConfig(t *testing.T, cfg translator.ImageConfig) { + t.Helper() + original := translator.DefaultGoImageConfig + translator.DefaultGoImageConfig = cfg + t.Cleanup(func() { translator.DefaultGoImageConfig = original }) +} + +// TestRuntime_GoImageConfig_FlatRepository tests that DefaultGoImageConfig.Repository is +// used verbatim, enabling flat-name registry layouts where the last-segment derivation +// would produce the wrong name (e.g. "kagent-golang-adk" instead of ".../golang-adk"). +func TestRuntime_GoImageConfig_FlatRepository(t *testing.T) { + withGoRuntimeDigests(t) + withGoImageConfig(t, translator.ImageConfig{Repository: "kagent-golang-adk"}) + + ctx := context.Background() + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "flat-repo-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "test", + ModelConfig: "test-model", + }, + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(agent, modelConfig).Build() + translatorInstance := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "test", Name: "test-model"}, nil, "", nil) + + result, err := translator.TranslateAgent(ctx, translatorInstance, agent) + require.NoError(t, err) + + var deployment *appsv1.Deployment + for _, obj := range result.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + deployment = dep + break + } + } + require.NotNil(t, deployment) + + img := deployment.Spec.Template.Spec.Containers[0].Image + assert.Contains(t, img, "kagent-golang-adk@", "Image should use the explicit flat repository") + assert.NotContains(t, img, "/golang-adk@", "Derived path must not appear when repository is explicitly set") + assert.Contains(t, img, "@sha256:test-go-base") +} + +// TestRuntime_GoImageConfig_ExplicitRegistry tests that DefaultGoImageConfig.Registry is +// applied to the Go runtime while leaving the Python runtime's registry unaffected. +func TestRuntime_GoImageConfig_ExplicitRegistry(t *testing.T) { + withGoRuntimeDigests(t) + withPythonRuntimeDigest(t) + withGoImageConfig(t, translator.ImageConfig{Registry: "my.registry.io"}) + + ctx := context.Background() + goAgent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "go-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "go", + ModelConfig: "test-model", + }, + }, + } + pythonAgent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "python-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Python, + SystemMessage: "python", + ModelConfig: "test-model", + }, + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(goAgent, pythonAgent, modelConfig).Build() + tn := types.NamespacedName{Namespace: "test", Name: "test-model"} + translatorInstance := translator.NewAdkApiTranslator(kubeClient, tn, nil, "", nil) + + goResult, err := translator.TranslateAgent(ctx, translatorInstance, goAgent) + require.NoError(t, err) + pythonResult, err := translator.TranslateAgent(ctx, translatorInstance, pythonAgent) + require.NoError(t, err) + + goImage, pythonImage := "", "" + for _, obj := range goResult.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + goImage = dep.Spec.Template.Spec.Containers[0].Image + break + } + } + for _, obj := range pythonResult.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + pythonImage = dep.Spec.Template.Spec.Containers[0].Image + break + } + } + + assert.Contains(t, goImage, "my.registry.io/", "Go image should use the explicit Go registry") + assert.NotContains(t, pythonImage, "my.registry.io/", "Python image must not use the Go-specific registry") +} + +// TestRuntime_GoImageConfig_FlatRepository_WithSkillsUsesFullDigest verifies that the +// golang-adk-full digest is still selected when DefaultGoImageConfig.Repository is set +// explicitly and the agent uses skills (which requires the full image). +func TestRuntime_GoImageConfig_FlatRepository_WithSkillsUsesFullDigest(t *testing.T) { + withGoRuntimeDigests(t) + withGoImageConfig(t, translator.ImageConfig{Repository: "kagent-golang-adk"}) + + ctx := context.Background() + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "flat-repo-skills-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "test", + ModelConfig: "test-model", + }, + Skills: &v1alpha2.SkillForAgent{Refs: []string{"example.com/skill:latest"}}, + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(agent, modelConfig).Build() + translatorInstance := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "test", Name: "test-model"}, nil, "", nil) + + result, err := translator.TranslateAgent(ctx, translatorInstance, agent) + require.NoError(t, err) + + var deployment *appsv1.Deployment + for _, obj := range result.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + deployment = dep + break + } + } + require.NotNil(t, deployment) + + img := deployment.Spec.Template.Spec.Containers[0].Image + assert.Contains(t, img, "kagent-golang-adk", "Image should use the explicit repository") + assert.Contains(t, img, "@sha256:test-go-full", "Skills agent should use the full digest") +} + +// TestRuntime_GoImageConfig_EmptyFallsBackToDerivation ensures backward compatibility: +// when DefaultGoImageConfig is all-empty the Go runtime image is derived exactly as before. +func TestRuntime_GoImageConfig_EmptyFallsBackToDerivation(t *testing.T) { + withGoRuntimeDigests(t) + withGoImageConfig(t, translator.ImageConfig{}) // all empty + + ctx := context.Background() + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "go-fallback-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "test", + ModelConfig: "test-model", + }, + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(agent, modelConfig).Build() + translatorInstance := translator.NewAdkApiTranslator(kubeClient, types.NamespacedName{Namespace: "test", Name: "test-model"}, nil, "", nil) + + result, err := translator.TranslateAgent(ctx, translatorInstance, agent) + require.NoError(t, err) + + var deployment *appsv1.Deployment + for _, obj := range result.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + deployment = dep + break + } + } + require.NotNil(t, deployment) + + img := deployment.Spec.Template.Spec.Containers[0].Image + assert.Contains(t, img, "golang-adk", "Fallback should still produce the derived golang-adk repository") + assert.Contains(t, img, "@sha256:test-go-base") +} + +// TestRuntime_GoImageConfig_ExplicitPullPolicy tests that DefaultGoImageConfig.PullPolicy +// is applied to the Go runtime and does not affect the Python runtime. +func TestRuntime_GoImageConfig_ExplicitPullPolicy(t *testing.T) { + withGoRuntimeDigests(t) + withPythonRuntimeDigest(t) + withGoImageConfig(t, translator.ImageConfig{PullPolicy: "Always"}) + + ctx := context.Background() + goAgent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "go-pullpolicy-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Go, + SystemMessage: "test", + ModelConfig: "test-model", + }, + }, + } + pythonAgent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "python-pullpolicy-agent", Namespace: "test"}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + Runtime: v1alpha2.DeclarativeRuntime_Python, + SystemMessage: "test", + ModelConfig: "test-model", + }, + }, + } + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "test-model", Namespace: "test"}, + Spec: v1alpha2.ModelConfigSpec{Provider: "OpenAI", Model: "gpt-4o"}, + } + + scheme := schemev1.Scheme + require.NoError(t, v1alpha2.AddToScheme(scheme)) + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(goAgent, pythonAgent, modelConfig).Build() + tn := types.NamespacedName{Namespace: "test", Name: "test-model"} + translatorInstance := translator.NewAdkApiTranslator(kubeClient, tn, nil, "", nil) + + goResult, err := translator.TranslateAgent(ctx, translatorInstance, goAgent) + require.NoError(t, err) + pythonResult, err := translator.TranslateAgent(ctx, translatorInstance, pythonAgent) + require.NoError(t, err) + + goPullPolicy, pythonPullPolicy := corev1.PullPolicy(""), corev1.PullPolicy("") + for _, obj := range goResult.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + goPullPolicy = dep.Spec.Template.Spec.Containers[0].ImagePullPolicy + break + } + } + for _, obj := range pythonResult.Manifest { + if dep, ok := obj.(*appsv1.Deployment); ok { + pythonPullPolicy = dep.Spec.Template.Spec.Containers[0].ImagePullPolicy + break + } + } + + assert.Equal(t, corev1.PullAlways, goPullPolicy, "Go runtime should use the explicit pull policy") + assert.NotEqual(t, corev1.PullAlways, pythonPullPolicy, "Python runtime must not inherit the Go-specific pull policy") +} diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 3781eefab8..a5be703400 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -218,6 +218,9 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Tag, "skills-init-image-tag", agent_translator.DefaultSkillsInitImageConfig.Tag, "The tag to use for the skills init image.") commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "skills-init-image-pull-policy", agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "The pull policy to use for the skills init image.") commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Repository, "skills-init-image-repository", agent_translator.DefaultSkillsInitImageConfig.Repository, "The repository to use for the skills init image.") + commandLine.StringVar(&agent_translator.DefaultGoImageConfig.Registry, "go-image-registry", agent_translator.DefaultGoImageConfig.Registry, "The registry to use for the Go (ADK) runtime agent image. When empty, falls back to --image-registry.") + commandLine.StringVar(&agent_translator.DefaultGoImageConfig.Repository, "go-image-repository", agent_translator.DefaultGoImageConfig.Repository, "The repository to use for the Go (ADK) runtime agent image. When empty, derived from --image-repository by replacing the last path segment with \"golang-adk\".") + commandLine.StringVar(&agent_translator.DefaultGoImageConfig.PullPolicy, "go-image-pull-policy", agent_translator.DefaultGoImageConfig.PullPolicy, "The pull policy to use for the Go (ADK) runtime agent image. When empty, falls back to --image-pull-policy.") commandLine.StringVar(&cfg.Openshell.GatewayURL, "openshell-gateway-url", "", "gRPC target for the OpenShell sandbox gateway (e.g. dns:///openshell.openshell.svc:443). When empty, the Sandbox controller is disabled.") commandLine.StringVar(&cfg.Openshell.Token, "openshell-token", "", "Static bearer token for the OpenShell gateway. Prefer --openshell-token-file for secrets.") diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 3f3dc04539..2e20686e6f 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -20,6 +20,15 @@ data: SKILLS_INIT_IMAGE_REGISTRY: {{ .Values.controller.skillsInitImage.registry | default .Values.registry | quote }} SKILLS_INIT_IMAGE_REPOSITORY: {{ .Values.controller.skillsInitImage.repository | quote }} SKILLS_INIT_IMAGE_TAG: {{ coalesce .Values.controller.skillsInitImage.tag .Values.tag .Chart.Version | quote }} + {{- with .Values.controller.goAgentImage.registry }} + GO_IMAGE_REGISTRY: {{ . | quote }} + {{- end }} + {{- with .Values.controller.goAgentImage.repository }} + GO_IMAGE_REPOSITORY: {{ . | quote }} + {{- end }} + {{- with .Values.controller.goAgentImage.pullPolicy }} + GO_IMAGE_PULL_POLICY: {{ . | quote }} + {{- end }} LEADER_ELECT: {{ include "kagent.leaderElectionEnabled" . | quote }} # OpenTelemetry Configuration OTEL_TRACING_ENABLED: {{ .Values.otel.tracing.enabled | quote }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 3cc24721fd..bfe77fc208 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -187,6 +187,16 @@ controller: repository: kagent-dev/kagent/skills-init tag: "" # Will default to global, then Chart version pullPolicy: "" + # -- The image used for the Go (ADK) runtime agent. + # Each field is optional: when empty, the controller falls back to the corresponding + # agentImage value (registry/pullPolicy) or derives the repository from + # agentImage.repository by replacing the last path segment with "golang-adk". + # Note: tag is not configurable here — Go runtime images are digest-pinned at + # controller build time. + goAgentImage: + registry: "" + repository: "" # empty => derived from agentImage.repository + pullPolicy: "" # -- @deprecated Removed in 0.10.0. The A2A SDK now handles SSE buffering and timeouts # internally. These values have no effect and will be removed in a future release. streaming: null