From aeeb11f2b0fb2a3b894eea6a217c958c40de6fa1 Mon Sep 17 00:00:00 2001 From: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:37:39 +0200 Subject: [PATCH 1/2] fix(controller): derive full runtime image from the tag name Digest-pinned IMAGE_TAG values produced an invalid OCI reference when skills agents appended -full after @sha256. Parse tag vs digest, suffix only the tag, and pin the full variant only with a dedicated digest. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> --- .../translator/agent/adk_api_translator.go | 57 +++++- .../translator/agent/deployments.go | 51 +++++- .../translator/agent/imageconfig_test.go | 172 ++++++++++++++++++ go/core/pkg/app/app.go | 4 +- .../templates/controller-configmap.yaml | 6 + .../tests/controller-deployment_test.yaml | 25 +++ helm/kagent/values.yaml | 10 + 7 files changed, 309 insertions(+), 16 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 a356cfea7..c10999ecd 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -108,6 +108,52 @@ func normalizeImageDigest(digest string) string { return "sha256:" + strings.TrimPrefix(digest, "sha256:") } +// splitImageTag splits a controller image-tag value into a name and an optional +// digest. IMAGE_TAG is the tag component of registry/repository:IMAGE_TAG, and +// operators sometimes embed a digest as "tag@sha256:hex". A digest-only value +// ("sha256:hex" or "@sha256:hex") has an empty name. +func splitImageTag(tag string) (name, digest string, err error) { + tag = strings.TrimSpace(tag) + if tag == "" { + return "", "", fmt.Errorf("image tag is empty") + } + name, rawDigest, hasDigest := strings.Cut(tag, "@") + name = strings.TrimSpace(name) + if !hasDigest { + if strings.HasPrefix(name, "sha256:") { + return "", normalizeImageDigest(name), nil + } + return name, "", nil + } + rawDigest = strings.TrimSpace(rawDigest) + if rawDigest == "" { + return "", "", fmt.Errorf("image tag %q has an empty digest", tag) + } + return name, normalizeImageDigest(rawDigest), nil +} + +func fullVariantTag(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("image tag has no name") + } + if strings.HasSuffix(name, "-full") { + return name, nil + } + return name + "-full", nil +} + +func formatImageRef(registry, repository, tag, digest string) string { + base := fmt.Sprintf("%s/%s", registry, repository) + if tag != "" && digest != "" { + return fmt.Sprintf("%s:%s@%s", base, tag, digest) + } + if digest != "" { + return fmt.Sprintf("%s@%s", base, digest) + } + return fmt.Sprintf("%s:%s", base, tag) +} + var DefaultImageConfig = ImageConfig{ Registry: "ghcr.io", Tag: version.Get().Version, @@ -119,10 +165,13 @@ var DefaultImageConfig = ImageConfig{ // PythonADKImageDigest, PythonADKFullImageDigest, GoADKImageDigest, and GoADKFullImageDigest // default to the pushed runtime image manifest digests baked in at controller link time, and // can be overridden at runtime via the --app[-full]-image-digest / --golang-adk[-full]-image-digest -// flags (for mirrored registries that re-assign digests). They are only consulted for sandbox -// agents — Substrate requires digest-pinned refs — while regular agents reference images by tag. -// The "full" variants bundle the sandbox runtime (code execution / bash tools); the slim -// variants do not. +// flags (for mirrored registries that re-assign digests). +// +// Sandbox agents always pin by these digests (Substrate rejects tag refs). Regular agents +// reference images by tag so mirrored registries that rewrite digests still resolve. +// When IMAGE_TAG embeds a digest (tag@sha256:...), the full variant also consults the +// dedicated full digest rather than reusing the slim digest. The "full" variants bundle +// the sandbox runtime (code execution / bash tools); the slim variants do not. var PythonADKImageDigest string var PythonADKFullImageDigest string var GoADKImageDigest string diff --git a/go/core/internal/controller/translator/agent/deployments.go b/go/core/internal/controller/translator/agent/deployments.go index 39355b786..770f45978 100644 --- a/go/core/internal/controller/translator/agent/deployments.go +++ b/go/core/internal/controller/translator/agent/deployments.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "slices" + "strings" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -149,23 +150,53 @@ func resolveGoRuntimeImage(registry string, full, pinDigest bool) (string, error // the repository and are published under "-full" (see APP_FULL_IMAGE_TAG / // GOLANG_ADK_FULL_IMAGE_TAG in the Makefile). // +// IMAGE_TAG is parsed as a tag, a tag@digest, or a digest-only value. The +// "-full" suffix is applied only to the tag name. A digest embedded in IMAGE_TAG +// is never reused on the full variant (the slim and full images have different +// manifests). If IMAGE_TAG includes a digest, the dedicated full digest is +// attached when set (--app-full-image-digest / --golang-adk-full-image-digest); +// otherwise the full image is referenced by tag only. +// // Sandbox agents require pinDigest: Substrate ActorTemplate validation rejects // image refs without a digest, so those use the link-time (or flag-overridden) // runtime image digests. func resolveRuntimeImage(registry, repository, tag, digest, imageLabel string, full, pinDigest bool) (string, error) { - if !pinDigest { - if full { - tag += "-full" + name, embeddedDigest, err := splitImageTag(tag) + if err != nil { + return "", fmt.Errorf("invalid %s image tag %q: %w", imageLabel, tag, err) + } + + if pinDigest { + if d := normalizeImageDigest(digest); d != "" { + return formatImageRef(registry, repository, "", d), nil } - return fmt.Sprintf("%s/%s:%s", registry, repository, tag), nil + return "", fmt.Errorf( + "%s image digest is not set; rebuild the controller after pushing agent runtime images, or override it via --%s-image-digest", + imageLabel, imageLabel, + ) + } + + if !full { + return formatImageRef(registry, repository, name, embeddedDigest), nil } - if d := normalizeImageDigest(digest); d != "" { - return fmt.Sprintf("%s/%s@%s", registry, repository, d), nil + + fullTag, err := fullVariantTag(name) + if err != nil { + if d := normalizeImageDigest(digest); d != "" { + return formatImageRef(registry, repository, "", d), nil + } + return "", fmt.Errorf( + "cannot derive %s image from digest-only tag %q: set a tag (for example 0.10.0-rc3) so the controller can use the published %s-full tag, or pin the full image via --%s-image-digest", + imageLabel, tag, strings.TrimSuffix(imageLabel, "-full"), imageLabel, + ) + } + + // Never reuse the slim digest on the full image. + var fullDigest string + if embeddedDigest != "" { + fullDigest = normalizeImageDigest(digest) } - return "", fmt.Errorf( - "%s image digest is not set; rebuild the controller after pushing agent runtime images, or override it via --%s-image-digest", - imageLabel, imageLabel, - ) + return formatImageRef(registry, repository, fullTag, fullDigest), nil } func resolveInlineDeployment(agent v1alpha2.AgentObject, mdd *modelDeploymentData) (*resolvedDeployment, error) { diff --git a/go/core/internal/controller/translator/agent/imageconfig_test.go b/go/core/internal/controller/translator/agent/imageconfig_test.go index 5b89607d6..412333d16 100644 --- a/go/core/internal/controller/translator/agent/imageconfig_test.go +++ b/go/core/internal/controller/translator/agent/imageconfig_test.go @@ -1,6 +1,7 @@ package agent import ( + "strings" "testing" "github.com/stretchr/testify/require" @@ -8,6 +9,11 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha2" ) +const ( + testSlimDigest = "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + testFullDigest = "sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" +) + func TestImageConfigImage(t *testing.T) { cfg := ImageConfig{ Registry: "ghcr.io", @@ -186,3 +192,169 @@ func TestResolveInlineDeploymentImagePinning(t *testing.T) { require.NoError(t, err) require.Contains(t, sdep.Image, "@sha256:pin-test", "sandbox agents require digest-pinned images (Substrate rejects tag refs)") } + +func TestSplitImageTag(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tag string + wantName string + wantDigest string + wantErr string + }{ + {name: "bare tag", tag: "0.10.0-rc3", wantName: "0.10.0-rc3"}, + {name: "tag at digest", tag: "0.10.0-rc3@" + testSlimDigest, wantName: "0.10.0-rc3", wantDigest: testSlimDigest}, + {name: "digest only", tag: testSlimDigest, wantDigest: testSlimDigest}, + {name: "at digest only", tag: "@" + testSlimDigest, wantDigest: testSlimDigest}, + {name: "empty", tag: " ", wantErr: "image tag is empty"}, + {name: "empty digest", tag: "0.10.0-rc3@", wantErr: "empty digest"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + gotName, gotDigest, err := splitImageTag(tt.tag) + if tt.wantErr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantName, gotName) + require.Equal(t, tt.wantDigest, gotDigest) + }) + } +} + +func TestResolveRuntimeImageDigestPinnedTagDoesNotAppendFullToDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + originalFull := PythonADKFullImageDigest + t.Cleanup(func() { + DefaultImageConfig.Tag = originalTag + PythonADKFullImageDigest = originalFull + }) + DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest + PythonADKFullImageDigest = "" + + got, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", got) + require.NotContains(t, got, "deadbeef") + require.False(t, strings.Contains(got, testSlimDigest+"-full"), "must not append -full after the slim digest") +} + +func TestResolveRuntimeImageDigestPinnedTagUsesDedicatedFullDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + originalFull := PythonADKFullImageDigest + t.Cleanup(func() { + DefaultImageConfig.Tag = originalTag + PythonADKFullImageDigest = originalFull + }) + DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest + PythonADKFullImageDigest = testFullDigest + + got, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full@"+testFullDigest, got) + require.NotContains(t, got, "deadbeef") +} + +func TestResolveRuntimeImageDigestPinnedTagDoesNotReuseSlimDigestForGo(t *testing.T) { + originalTag := DefaultGoImageConfig.Tag + originalFull := GoADKFullImageDigest + t.Cleanup(func() { + DefaultGoImageConfig.Tag = originalTag + GoADKFullImageDigest = originalFull + }) + DefaultGoImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest + GoADKFullImageDigest = testFullDigest + + got, err := resolveGoRuntimeImage("ghcr.io", true, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/golang-adk:0.10.0-rc3-full@"+testFullDigest, got) + require.NotContains(t, got, "deadbeef") +} + +func TestResolveRuntimeImagePreservesEmbeddedDigestForSlimImage(t *testing.T) { + originalTag := DefaultImageConfig.Tag + t.Cleanup(func() { DefaultImageConfig.Tag = originalTag }) + DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest + + got, err := resolvePythonRuntimeImage("ghcr.io", false, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3@"+testSlimDigest, got) +} + +func TestResolveRuntimeImageDoesNotDoubleFullSuffix(t *testing.T) { + originalTag := DefaultImageConfig.Tag + t.Cleanup(func() { DefaultImageConfig.Tag = originalTag }) + DefaultImageConfig.Tag = "0.10.0-rc3-full" + + got, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", got) +} + +func TestResolveRuntimeImageDigestOnlyTagFailsClosedWithoutFullDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + originalFull := PythonADKFullImageDigest + t.Cleanup(func() { + DefaultImageConfig.Tag = originalTag + PythonADKFullImageDigest = originalFull + }) + DefaultImageConfig.Tag = testSlimDigest + PythonADKFullImageDigest = "" + + _, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.Error(t, err) + require.Contains(t, err.Error(), "digest-only") + require.Contains(t, err.Error(), "app-full-image-digest") +} + +func TestResolveRuntimeImageDigestOnlyTagUsesDedicatedFullDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + originalFull := PythonADKFullImageDigest + t.Cleanup(func() { + DefaultImageConfig.Tag = originalTag + PythonADKFullImageDigest = originalFull + }) + DefaultImageConfig.Tag = testSlimDigest + PythonADKFullImageDigest = testFullDigest + + got, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app@"+testFullDigest, got) +} + +func TestResolveRuntimeImageRejectsEmptyDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + t.Cleanup(func() { DefaultImageConfig.Tag = originalTag }) + DefaultImageConfig.Tag = "0.10.0-rc3@" + + _, err := resolvePythonRuntimeImage("ghcr.io", true, false) + require.Error(t, err) + require.Contains(t, err.Error(), "empty digest") +} + +func TestResolveInlineDeploymentSkillsDropsEmbeddedSlimDigest(t *testing.T) { + originalTag := DefaultImageConfig.Tag + originalFull := PythonADKFullImageDigest + t.Cleanup(func() { + DefaultImageConfig.Tag = originalTag + PythonADKFullImageDigest = originalFull + }) + DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest + PythonADKFullImageDigest = "" + + agent := &v1alpha2.Agent{ + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{SystemMessage: "test", ModelConfig: "test-model"}, + Skills: &v1alpha2.SkillForAgent{Refs: []string{"example.com/skill:latest"}}, + }, + } + dep, err := resolveInlineDeployment(agent, &modelDeploymentData{}) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", dep.Image) + require.NotContains(t, dep.Image, "deadbeef") +} diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index f093ce35a..8ae8cdb7a 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -215,9 +215,9 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&agent_translator.DefaultImageConfig.PullSecret, "image-pull-secret", "", "The pull secret name for the agent image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Repository, "image-repository", agent_translator.DefaultImageConfig.Repository, "The repository to use for the agent image.") commandLine.StringVar(&agent_translator.PythonADKImageDigest, "app-image-digest", agent_translator.PythonADKImageDigest, "Manifest digest (sha256:...) for the Python agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") - commandLine.StringVar(&agent_translator.PythonADKFullImageDigest, "app-full-image-digest", agent_translator.PythonADKFullImageDigest, "Manifest digest (sha256:...) for the full Python agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") + commandLine.StringVar(&agent_translator.PythonADKFullImageDigest, "app-full-image-digest", agent_translator.PythonADKFullImageDigest, "Manifest digest (sha256:...) for the full Python agent runtime image (skills/SRT). Required for sandbox agents; also used for declarative skills agents when IMAGE_TAG embeds a digest. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") commandLine.StringVar(&agent_translator.GoADKImageDigest, "golang-adk-image-digest", agent_translator.GoADKImageDigest, "Manifest digest (sha256:...) for the Go agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") - commandLine.StringVar(&agent_translator.GoADKFullImageDigest, "golang-adk-full-image-digest", agent_translator.GoADKFullImageDigest, "Manifest digest (sha256:...) for the full Go agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") + commandLine.StringVar(&agent_translator.GoADKFullImageDigest, "golang-adk-full-image-digest", agent_translator.GoADKFullImageDigest, "Manifest digest (sha256:...) for the full Go agent runtime image (skills/SRT). Required for sandbox agents; also used for declarative skills agents when GO_IMAGE_TAG embeds a digest. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Registry, "skills-init-image-registry", agent_translator.DefaultSkillsInitImageConfig.Registry, "The registry to use for the skills init image.") 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.") diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 2f21b0119..0a04a14dd 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -19,6 +19,9 @@ data: IMAGE_REGISTRY: {{ .Values.controller.agentImage.registry | default .Values.registry | quote }} IMAGE_REPOSITORY: {{ .Values.controller.agentImage.repository | quote }} IMAGE_TAG: {{ coalesce .Values.controller.agentImage.tag .Values.tag .Chart.Version | quote }} + {{- if and .Values.controller.agentImage.fullDigest (not (eq .Values.controller.agentImage.fullDigest "")) }} + APP_FULL_IMAGE_DIGEST: {{ .Values.controller.agentImage.fullDigest | quote }} + {{- end }} SKILLS_INIT_IMAGE_PULL_POLICY: {{ .Values.controller.skillsInitImage.pullPolicy | default .Values.imagePullPolicy | quote }} SKILLS_INIT_IMAGE_REGISTRY: {{ .Values.controller.skillsInitImage.registry | default .Values.registry | quote }} SKILLS_INIT_IMAGE_REPOSITORY: {{ .Values.controller.skillsInitImage.repository | quote }} @@ -27,6 +30,9 @@ data: GO_IMAGE_REGISTRY: {{ .Values.controller.goAgentImage.registry | default .Values.registry | quote }} GO_IMAGE_REPOSITORY: {{ .Values.controller.goAgentImage.repository | quote }} GO_IMAGE_TAG: {{ coalesce .Values.controller.goAgentImage.tag .Values.tag .Chart.Version | quote }} + {{- if and .Values.controller.goAgentImage.fullDigest (not (eq .Values.controller.goAgentImage.fullDigest "")) }} + GOLANG_ADK_FULL_IMAGE_DIGEST: {{ .Values.controller.goAgentImage.fullDigest | quote }} + {{- end }} LEADER_ELECT: {{ include "kagent.leaderElectionEnabled" . | quote }} # OpenTelemetry Configuration OTEL_TRACING_ENABLED: {{ .Values.otel.tracing.enabled | quote }} diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index 66b1faa65..620053f86 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -235,6 +235,31 @@ tests: - equal: path: data.IMAGE_PULL_SECRET value: "pull-secret" + - it: should set APP_FULL_IMAGE_DIGEST when agentImage.fullDigest is set + template: controller-configmap.yaml + set: + controller: + agentImage: + fullDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + asserts: + - equal: + path: data.APP_FULL_IMAGE_DIGEST + value: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + - it: should not set APP_FULL_IMAGE_DIGEST when agentImage.fullDigest is unset + template: controller-configmap.yaml + asserts: + - notExists: + path: data.APP_FULL_IMAGE_DIGEST + - it: should set GOLANG_ADK_FULL_IMAGE_DIGEST when goAgentImage.fullDigest is set + template: controller-configmap.yaml + set: + controller: + goAgentImage: + fullDigest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + asserts: + - equal: + path: data.GOLANG_ADK_FULL_IMAGE_DIGEST + value: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - it: should not use controller.agentImage.pullSecret when not set template: controller-configmap.yaml set: diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 123053d7a..84608ab09 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -218,6 +218,12 @@ controller: registry: "" repository: kagent-dev/kagent/app tag: "" # Will default to global, then Chart version + # -- Manifest digest (sha256:...) of the full (skills/SRT) Python agent image. + # Forwarded as APP_FULL_IMAGE_DIGEST. Set this when agentImage.tag embeds a + # digest (tag@sha256:...) so skills-enabled agents pin the full variant + # rather than the slim digest. Also used by sandbox agents. + # @default -- "" (controller uses the link-time digest, or tag-only) + fullDigest: "" pullPolicy: "" # -- Image pull secret name set on agent pods created by the controller pullSecret: "" @@ -232,6 +238,10 @@ controller: registry: "" repository: kagent-dev/kagent/golang-adk tag: "" # Will default to global, then Chart version + # -- Manifest digest (sha256:...) of the full (skills/SRT) Go agent image. + # Forwarded as GOLANG_ADK_FULL_IMAGE_DIGEST. Same rules as agentImage.fullDigest. + # @default -- "" (controller uses the link-time digest, or tag-only) + fullDigest: "" 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. From 28642e1a0ca4387339d49398d9b819130e8f3332 Mon Sep 17 00:00:00 2001 From: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:21:02 +0200 Subject: [PATCH 2/2] fix(controller): pin full images only on an explicit digest override Released builds always bake APP full digests via ldflags. Treat that value as sandbox fallback only. Declarative skills agents pin the full variant only when Helm fullDigest or the matching flag/env is set. Signed-off-by: Ricardo Temperini <29879569+rtemperini@users.noreply.github.com> --- .../translator/agent/adk_api_translator.go | 37 +++++++-- .../translator/agent/deployments.go | 11 +-- .../translator/agent/imageconfig_test.go | 81 ++++++++++++++++--- go/core/pkg/app/app.go | 4 +- helm/kagent/values.yaml | 18 +++-- 5 files changed, 119 insertions(+), 32 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 c10999ecd..3f083c6ba 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -163,19 +163,40 @@ var DefaultImageConfig = ImageConfig{ } // PythonADKImageDigest, PythonADKFullImageDigest, GoADKImageDigest, and GoADKFullImageDigest -// default to the pushed runtime image manifest digests baked in at controller link time, and -// can be overridden at runtime via the --app[-full]-image-digest / --golang-adk[-full]-image-digest -// flags (for mirrored registries that re-assign digests). +// default to the pushed runtime image manifest digests baked in at controller link time +// (scripts/controller-digest-ldflags.sh). Released controller images always have the +// full-variant values populated. // -// Sandbox agents always pin by these digests (Substrate rejects tag refs). Regular agents -// reference images by tag so mirrored registries that rewrite digests still resolve. -// When IMAGE_TAG embeds a digest (tag@sha256:...), the full variant also consults the -// dedicated full digest rather than reusing the slim digest. The "full" variants bundle -// the sandbox runtime (code execution / bash tools); the slim variants do not. +// PythonADKFullImageDigestOverride and GoADKFullImageDigestOverride are empty unless the +// operator sets --app-full-image-digest / --golang-adk-full-image-digest (or the matching +// APP_FULL_IMAGE_DIGEST / GOLANG_ADK_FULL_IMAGE_DIGEST env, including Helm fullDigest). +// That is the only signal that a declarative skills agent should digest-pin the full +// variant: the baked digest is the upstream image, which may not match a mirror or the +// digest the operator embedded in IMAGE_TAG. +// +// Sandbox agents always pin (Substrate rejects tag refs): override if set, else baked. +// Regular agents reference images by tag so mirrored registries that rewrite digests +// still resolve. The "full" variants bundle the sandbox runtime (code execution / bash +// tools); the slim variants do not. var PythonADKImageDigest string var PythonADKFullImageDigest string var GoADKImageDigest string var GoADKFullImageDigest string +var PythonADKFullImageDigestOverride string +var GoADKFullImageDigestOverride string + +// fullRuntimeDigest returns the digest to use for a full-variant image. +// An explicit runtime override always wins. Sandbox (pinDigest) falls back to the +// link-time digest. Declarative agents do not: a baked digest is not an operator pin. +func fullRuntimeDigest(baked, override string, pinDigest bool) string { + if d := strings.TrimSpace(override); d != "" { + return d + } + if pinDigest { + return baked + } + return "" +} // DefaultGoImageConfig is the image config for the Go (ADK) runtime agent. // Regular agents reference it by tag; sandbox agents pin by digest via diff --git a/go/core/internal/controller/translator/agent/deployments.go b/go/core/internal/controller/translator/agent/deployments.go index 770f45978..30e35b1f5 100644 --- a/go/core/internal/controller/translator/agent/deployments.go +++ b/go/core/internal/controller/translator/agent/deployments.go @@ -125,7 +125,7 @@ func resolvePythonRuntimeImage(registry string, full, pinDigest bool) (string, e digest := PythonADKImageDigest imageLabel := "app" if full { - digest = PythonADKFullImageDigest + digest = fullRuntimeDigest(PythonADKFullImageDigest, PythonADKFullImageDigestOverride, pinDigest) imageLabel = "app-full" } return resolveRuntimeImage(registry, repo, DefaultImageConfig.Tag, digest, imageLabel, full, pinDigest) @@ -136,7 +136,7 @@ func resolveGoRuntimeImage(registry string, full, pinDigest bool) (string, error digest := GoADKImageDigest imageLabel := "golang-adk" if full { - digest = GoADKFullImageDigest + digest = fullRuntimeDigest(GoADKFullImageDigest, GoADKFullImageDigestOverride, pinDigest) imageLabel = "golang-adk-full" } return resolveRuntimeImage(registry, repo, DefaultGoImageConfig.Tag, digest, imageLabel, full, pinDigest) @@ -153,9 +153,10 @@ func resolveGoRuntimeImage(registry string, full, pinDigest bool) (string, error // IMAGE_TAG is parsed as a tag, a tag@digest, or a digest-only value. The // "-full" suffix is applied only to the tag name. A digest embedded in IMAGE_TAG // is never reused on the full variant (the slim and full images have different -// manifests). If IMAGE_TAG includes a digest, the dedicated full digest is -// attached when set (--app-full-image-digest / --golang-adk-full-image-digest); -// otherwise the full image is referenced by tag only. +// manifests). If IMAGE_TAG includes a digest, the full image is referenced by +// tag only unless the operator set an explicit runtime full digest (Helm +// fullDigest / APP_FULL_IMAGE_DIGEST / --app-full-image-digest). The link-time +// baked digest is not that signal: released builds always populate it. // // Sandbox agents require pinDigest: Substrate ActorTemplate validation rejects // image refs without a digest, so those use the link-time (or flag-overridden) diff --git a/go/core/internal/controller/translator/agent/imageconfig_test.go b/go/core/internal/controller/translator/agent/imageconfig_test.go index 412333d16..aeaaec5a8 100644 --- a/go/core/internal/controller/translator/agent/imageconfig_test.go +++ b/go/core/internal/controller/translator/agent/imageconfig_test.go @@ -226,53 +226,106 @@ func TestSplitImageTag(t *testing.T) { } } +func TestFullRuntimeDigest(t *testing.T) { + t.Parallel() + require.Equal(t, testFullDigest, fullRuntimeDigest("sha256:baked", testFullDigest, false)) + require.Equal(t, testFullDigest, fullRuntimeDigest("sha256:baked", testFullDigest, true)) + require.Equal(t, "", fullRuntimeDigest("sha256:baked", "", false), "baked digest is not a declarative pin") + require.Equal(t, "sha256:baked", fullRuntimeDigest("sha256:baked", "", true)) + require.Equal(t, "", fullRuntimeDigest("", "", false)) +} + func TestResolveRuntimeImageDigestPinnedTagDoesNotAppendFullToDigest(t *testing.T) { originalTag := DefaultImageConfig.Tag originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride t.Cleanup(func() { DefaultImageConfig.Tag = originalTag PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride }) + // Released controller builds always bake a full digest. That must not pin + // the declarative full image, or IMAGE_TAG=tag@digest becomes tag-full@baked. DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest - PythonADKFullImageDigest = "" + PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigestOverride = "" got, err := resolvePythonRuntimeImage("ghcr.io", true, false) require.NoError(t, err) require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", got) require.NotContains(t, got, "deadbeef") + require.NotContains(t, got, testFullDigest) require.False(t, strings.Contains(got, testSlimDigest+"-full"), "must not append -full after the slim digest") } -func TestResolveRuntimeImageDigestPinnedTagUsesDedicatedFullDigest(t *testing.T) { +func TestResolveRuntimeImageDigestPinnedTagUsesExplicitFullDigest(t *testing.T) { originalTag := DefaultImageConfig.Tag originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride t.Cleanup(func() { DefaultImageConfig.Tag = originalTag PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride }) DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest - PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigest = "sha256:bakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbake" + PythonADKFullImageDigestOverride = testFullDigest got, err := resolvePythonRuntimeImage("ghcr.io", true, false) require.NoError(t, err) require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full@"+testFullDigest, got) require.NotContains(t, got, "deadbeef") + require.NotContains(t, got, "baked") } func TestResolveRuntimeImageDigestPinnedTagDoesNotReuseSlimDigestForGo(t *testing.T) { originalTag := DefaultGoImageConfig.Tag originalFull := GoADKFullImageDigest + originalOverride := GoADKFullImageDigestOverride t.Cleanup(func() { DefaultGoImageConfig.Tag = originalTag GoADKFullImageDigest = originalFull + GoADKFullImageDigestOverride = originalOverride }) DefaultGoImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest - GoADKFullImageDigest = testFullDigest + GoADKFullImageDigest = "sha256:bakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbake" + GoADKFullImageDigestOverride = testFullDigest got, err := resolveGoRuntimeImage("ghcr.io", true, false) require.NoError(t, err) require.Equal(t, "ghcr.io/kagent-dev/kagent/golang-adk:0.10.0-rc3-full@"+testFullDigest, got) require.NotContains(t, got, "deadbeef") + require.NotContains(t, got, "baked") +} + +func TestResolveRuntimeImageBakedFullDigestStillPinsSandbox(t *testing.T) { + originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride + t.Cleanup(func() { + PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride + }) + PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigestOverride = "" + + got, err := resolvePythonRuntimeImage("ghcr.io", true, true) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app@"+testFullDigest, got) +} + +func TestResolveRuntimeImageExplicitFullDigestOverridesSandboxBaked(t *testing.T) { + originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride + t.Cleanup(func() { + PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride + }) + PythonADKFullImageDigest = "sha256:bakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbake" + PythonADKFullImageDigestOverride = testFullDigest + + got, err := resolvePythonRuntimeImage("ghcr.io", true, true) + require.NoError(t, err) + require.Equal(t, "ghcr.io/kagent-dev/kagent/app@"+testFullDigest, got) } func TestResolveRuntimeImagePreservesEmbeddedDigestForSlimImage(t *testing.T) { @@ -295,15 +348,18 @@ func TestResolveRuntimeImageDoesNotDoubleFullSuffix(t *testing.T) { require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", got) } -func TestResolveRuntimeImageDigestOnlyTagFailsClosedWithoutFullDigest(t *testing.T) { +func TestResolveRuntimeImageDigestOnlyTagFailsClosedWithoutExplicitFullDigest(t *testing.T) { originalTag := DefaultImageConfig.Tag originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride t.Cleanup(func() { DefaultImageConfig.Tag = originalTag PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride }) DefaultImageConfig.Tag = testSlimDigest - PythonADKFullImageDigest = "" + PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigestOverride = "" _, err := resolvePythonRuntimeImage("ghcr.io", true, false) require.Error(t, err) @@ -311,15 +367,18 @@ func TestResolveRuntimeImageDigestOnlyTagFailsClosedWithoutFullDigest(t *testing require.Contains(t, err.Error(), "app-full-image-digest") } -func TestResolveRuntimeImageDigestOnlyTagUsesDedicatedFullDigest(t *testing.T) { +func TestResolveRuntimeImageDigestOnlyTagUsesExplicitFullDigest(t *testing.T) { originalTag := DefaultImageConfig.Tag originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride t.Cleanup(func() { DefaultImageConfig.Tag = originalTag PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride }) DefaultImageConfig.Tag = testSlimDigest - PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigest = "sha256:bakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbakedbake" + PythonADKFullImageDigestOverride = testFullDigest got, err := resolvePythonRuntimeImage("ghcr.io", true, false) require.NoError(t, err) @@ -339,12 +398,15 @@ func TestResolveRuntimeImageRejectsEmptyDigest(t *testing.T) { func TestResolveInlineDeploymentSkillsDropsEmbeddedSlimDigest(t *testing.T) { originalTag := DefaultImageConfig.Tag originalFull := PythonADKFullImageDigest + originalOverride := PythonADKFullImageDigestOverride t.Cleanup(func() { DefaultImageConfig.Tag = originalTag PythonADKFullImageDigest = originalFull + PythonADKFullImageDigestOverride = originalOverride }) DefaultImageConfig.Tag = "0.10.0-rc3@" + testSlimDigest - PythonADKFullImageDigest = "" + PythonADKFullImageDigest = testFullDigest + PythonADKFullImageDigestOverride = "" agent := &v1alpha2.Agent{ Spec: v1alpha2.AgentSpec{ @@ -357,4 +419,5 @@ func TestResolveInlineDeploymentSkillsDropsEmbeddedSlimDigest(t *testing.T) { require.NoError(t, err) require.Equal(t, "ghcr.io/kagent-dev/kagent/app:0.10.0-rc3-full", dep.Image) require.NotContains(t, dep.Image, "deadbeef") + require.NotContains(t, dep.Image, testFullDigest) } diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 8ae8cdb7a..2ab30303d 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -215,9 +215,9 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&agent_translator.DefaultImageConfig.PullSecret, "image-pull-secret", "", "The pull secret name for the agent image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Repository, "image-repository", agent_translator.DefaultImageConfig.Repository, "The repository to use for the agent image.") commandLine.StringVar(&agent_translator.PythonADKImageDigest, "app-image-digest", agent_translator.PythonADKImageDigest, "Manifest digest (sha256:...) for the Python agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") - commandLine.StringVar(&agent_translator.PythonADKFullImageDigest, "app-full-image-digest", agent_translator.PythonADKFullImageDigest, "Manifest digest (sha256:...) for the full Python agent runtime image (skills/SRT). Required for sandbox agents; also used for declarative skills agents when IMAGE_TAG embeds a digest. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") + commandLine.StringVar(&agent_translator.PythonADKFullImageDigestOverride, "app-full-image-digest", "", "Explicit manifest digest (sha256:...) for the full Python agent runtime image (skills/SRT). Sandbox agents fall back to the link-time digest when this is unset. Declarative skills agents pin the full variant only when this is set (IMAGE_TAG embedding a digest is not enough, because released builds always bake an upstream digest).") commandLine.StringVar(&agent_translator.GoADKImageDigest, "golang-adk-image-digest", agent_translator.GoADKImageDigest, "Manifest digest (sha256:...) for the Go agent runtime image used by sandbox agents. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") - commandLine.StringVar(&agent_translator.GoADKFullImageDigest, "golang-adk-full-image-digest", agent_translator.GoADKFullImageDigest, "Manifest digest (sha256:...) for the full Go agent runtime image (skills/SRT). Required for sandbox agents; also used for declarative skills agents when GO_IMAGE_TAG embeds a digest. Defaults to the digest baked in at build time; override when a mirrored registry re-assigns digests.") + commandLine.StringVar(&agent_translator.GoADKFullImageDigestOverride, "golang-adk-full-image-digest", "", "Explicit manifest digest (sha256:...) for the full Go agent runtime image (skills/SRT). Same override rules as --app-full-image-digest.") commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Registry, "skills-init-image-registry", agent_translator.DefaultSkillsInitImageConfig.Registry, "The registry to use for the skills init image.") 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.") diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 84608ab09..f9eb4e455 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -218,11 +218,12 @@ controller: registry: "" repository: kagent-dev/kagent/app tag: "" # Will default to global, then Chart version - # -- Manifest digest (sha256:...) of the full (skills/SRT) Python agent image. - # Forwarded as APP_FULL_IMAGE_DIGEST. Set this when agentImage.tag embeds a - # digest (tag@sha256:...) so skills-enabled agents pin the full variant - # rather than the slim digest. Also used by sandbox agents. - # @default -- "" (controller uses the link-time digest, or tag-only) + # -- Explicit manifest digest (sha256:...) of the full (skills/SRT) Python + # agent image. Forwarded as APP_FULL_IMAGE_DIGEST. Required to digest-pin + # declarative skills agents when agentImage.tag embeds a digest + # (tag@sha256:...); the controller's link-time digest is not used for that + # path. Sandbox agents use this when set, otherwise the link-time digest. + # @default -- "" (declarative: tag-only full image; sandbox: link-time digest) fullDigest: "" pullPolicy: "" # -- Image pull secret name set on agent pods created by the controller @@ -238,9 +239,10 @@ controller: registry: "" repository: kagent-dev/kagent/golang-adk tag: "" # Will default to global, then Chart version - # -- Manifest digest (sha256:...) of the full (skills/SRT) Go agent image. - # Forwarded as GOLANG_ADK_FULL_IMAGE_DIGEST. Same rules as agentImage.fullDigest. - # @default -- "" (controller uses the link-time digest, or tag-only) + # -- Explicit manifest digest (sha256:...) of the full (skills/SRT) Go + # agent image. Forwarded as GOLANG_ADK_FULL_IMAGE_DIGEST. Same rules as + # agentImage.fullDigest. + # @default -- "" (declarative: tag-only full image; sandbox: link-time digest) fullDigest: "" pullPolicy: "" # -- @deprecated Removed in 0.10.0. The A2A SDK now handles SSE buffering and timeouts