Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -117,16 +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). 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.
// 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.
//
// 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
Expand Down
56 changes: 44 additions & 12 deletions go/core/internal/controller/translator/agent/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"maps"
"slices"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
Expand Down Expand Up @@ -124,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)
Expand All @@ -135,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)
Expand All @@ -149,23 +150,54 @@ func resolveGoRuntimeImage(registry string, full, pinDigest bool) (string, error
// the repository and are published under "<tag>-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 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)
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we align the fallback with what the behavior is here? Controller builds always populate the full image digests with the ldflags script, so digest will be non-empty even when the Helm fullDigest is unset.

For IMAGE_TAG=tag@digest, this emits tag-full@<baked upstream digest>; the tag-only fallback described above and in the PR body is reachable only in builds without those linker flags.

So this needs a way to distinguish between an explicit runtime override and a baked-in digtest.

@rtemperini rtemperini Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, declarative skills agents now pin the full variant only when fullDigest (APP_FULL_IMAGE_DIGEST) is set at runtime. The baked digest still applies to sandbox agents. IMAGE_TAG=tag@digest with no runtime override is now app:tag-full, never the slim digest and never the baked upstream digest.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let me know if this covers this case for baked digest without runtime override

}
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) {
Expand Down
Loading
Loading