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
2 changes: 1 addition & 1 deletion docker/skills-init/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ FROM alpine:3.23
ARG PYTHON_UID=1001
ARG PYTHON_GID=1001

RUN apk upgrade --no-cache && apk add --no-cache git
RUN apk upgrade --no-cache && apk add --no-cache git jq
COPY --from=krane-builder /build/krane /usr/local/bin/krane

# Run as the same UID/GID as the main agent container (python user) so that
Expand Down
25 changes: 25 additions & 0 deletions go/api/config/crd/bases/kagent.dev_agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13319,6 +13319,31 @@ spec:
maxItems: 20
minItems: 1
type: array
imagePullSecrets:
description: |-
ImagePullSecrets is a list of references to secrets in the same namespace to use for
pulling skill images from private registries. Each referenced secret must be of type
kubernetes.io/dockerconfigjson. The credentials from all secrets are merged and made
available to the skills-init container at /.kagent/.docker/config.json; krane will
use them automatically when pulling images.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
maxItems: 20
type: array
initContainer:
description: Configuration for the skills-init init container.
properties:
Expand Down
25 changes: 25 additions & 0 deletions go/api/config/crd/bases/kagent.dev_sandboxagents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10969,6 +10969,31 @@ spec:
maxItems: 20
minItems: 1
type: array
imagePullSecrets:
description: |-
ImagePullSecrets is a list of references to secrets in the same namespace to use for
pulling skill images from private registries. Each referenced secret must be of type
kubernetes.io/dockerconfigjson. The credentials from all secrets are merged and made
available to the skills-init container at /.kagent/.docker/config.json; krane will
use them automatically when pulling images.
items:
description: |-
LocalObjectReference contains enough information to let you locate the
referenced object inside the same namespace.
properties:
name:
default: ""
description: |-
Name of the referent.
This field is effectively required, but due to backwards compatibility is
allowed to be empty. Instances of this type with an empty value here are
almost certainly wrong.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
type: object
x-kubernetes-map-type: atomic
maxItems: 20
type: array
initContainer:
description: Configuration for the skills-init init container.
properties:
Expand Down
9 changes: 9 additions & 0 deletions go/api/v1alpha2/agent_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ type SkillForAgent struct {
// +optional
Refs []string `json:"refs,omitempty"`

// ImagePullSecrets is a list of references to secrets in the same namespace to use for
// pulling skill images from private registries. Each referenced secret must be of type
// kubernetes.io/dockerconfigjson. The credentials from all secrets are merged and made
// available to the skills-init container at /.kagent/.docker/config.json; krane will
// use them automatically when pulling images.
// +optional
// +kubebuilder:validation:MaxItems=20
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`

// Reference to a Secret containing git credentials.
// Applied to all gitRefs entries.
// The secret should contain a `token` key for HTTPS auth,
Expand Down
5 changes: 5 additions & 0 deletions go/api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 97 additions & 5 deletions go/core/internal/controller/translator/agent/adk_api_translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -1214,6 +1214,17 @@ var skillsInitScriptTmpl string
// skillsScriptTemplate is the shell script template for fetching skills from Git and OCI.
var skillsScriptTemplate = template.Must(template.New("skills-init").Parse(skillsInitScriptTmpl))

// dockerAuthInitData holds the secret names for the docker-auth-init script template.
type dockerAuthInitData struct {
Secrets []string
}

//go:embed docker-auth-init.sh.tmpl
var dockerAuthInitScriptTmpl string

// dockerAuthInitTemplate is the shell script template for merging Docker auth credentials.
var dockerAuthInitTemplate = template.Must(template.New("docker-auth-init").Parse(dockerAuthInitScriptTmpl))

// buildSkillsScript renders the unified skills-init shell script.
func buildSkillsScript(data skillsInitData) (string, error) {
var buf bytes.Buffer
Expand Down Expand Up @@ -1324,6 +1335,9 @@ func prepareSkillsInitData(
// buildSkillsInitContainer creates the unified init container and associated volumes
// for fetching skills from both Git repositories and OCI registries.
// If authSecretRef is non-nil a single Secret volume is created and mounted at /git-auth.
// If imagePullSecrets is non-empty, a docker-auth-init container is prepended that merges
// all kubernetes.io/dockerconfigjson secrets into /.kagent/.docker/config.json; krane
// reads the directory via the DOCKER_CONFIG env var and uses those credentials automatically.
func buildSkillsInitContainer(
gitRefs []v1alpha2.GitRepo,
authSecretRef *corev1.LocalObjectReference,
Expand All @@ -1332,14 +1346,15 @@ func buildSkillsInitContainer(
securityContext *corev1.SecurityContext,
env []corev1.EnvVar,
resources corev1.ResourceRequirements,
) (container corev1.Container, volumes []corev1.Volume, err error) {
imagePullSecrets []corev1.LocalObjectReference,
) (containers []corev1.Container, volumes []corev1.Volume, err error) {
data, err := prepareSkillsInitData(gitRefs, authSecretRef, ociRefs, insecureOCI)
if err != nil {
return corev1.Container{}, nil, err
return nil, nil, err
}
script, err := buildSkillsScript(data)
if err != nil {
return corev1.Container{}, nil, err
return nil, nil, err
}
initSecCtx := securityContext
if initSecCtx != nil {
Expand Down Expand Up @@ -1367,7 +1382,60 @@ func buildSkillsInitContainer(
})
}

container = corev1.Container{
// If imagePullSecrets are specified, build a docker-auth-init container that merges all
// kubernetes.io/dockerconfigjson secrets into a single config.json using jq, then mount
// the result into the skills-init container so krane can authenticate to private registries.
if len(imagePullSecrets) > 0 {
// Shared EmptyDir volume for the merged Docker config.
volumes = append(volumes, corev1.Volume{
Name: "kagent-docker-config",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})

// Mount each imagePullSecret as a read-only directory under /docker-secrets/<name>.
authInitVolumeMounts := []corev1.VolumeMount{
{Name: "kagent-docker-config", MountPath: "/docker-config-out"},
}
for _, secret := range imagePullSecrets {
volName := "pull-secret-" + secret.Name
volumes = append(volumes, corev1.Volume{
Name: volName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secret.Name,
},
},
})
Comment on lines +1401 to +1410
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

Volume names are derived from the Secret name ("pull-secret-" + secret.Name). Kubernetes Secret names may contain characters (notably '.') and/or length that are invalid for Pod volume names, which can make the generated Deployment fail admission. Consider generating a safe volume name (e.g., index-based or hashed) and keep the SecretName field pointing at the original secret.

Copilot uses AI. Check for mistakes.
authInitVolumeMounts = append(authInitVolumeMounts, corev1.VolumeMount{
Name: volName,
MountPath: "/docker-secrets/" + secret.Name,
ReadOnly: true,
})
}

mergeScript, err := buildDockerAuthMergeScript(imagePullSecrets)
if err != nil {
return nil, nil, err
}
dockerAuthInitContainer := corev1.Container{
Name: "docker-auth-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", mergeScript},
VolumeMounts: authInitVolumeMounts,
Comment on lines +1423 to +1426
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

docker-auth-init is created without SecurityContext or resource requirements, while skills-init uses the pod/deployment securityContext and configured resources. This can cause PodSecurity admission failures or unexpected resource usage differences. Consider applying the same initSecCtx and resources (or a deliberate minimal set) to docker-auth-init as well.

Suggested change
Name: "docker-auth-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", mergeScript},
VolumeMounts: authInitVolumeMounts,
Name: "docker-auth-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", mergeScript},
VolumeMounts: authInitVolumeMounts,
SecurityContext: initSecCtx,
Resources: resources,

Copilot uses AI. Check for mistakes.
}
containers = append(containers, dockerAuthInitContainer)

// Mount the merged config into skills-init so krane picks it up via DOCKER_CONFIG.
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: "kagent-docker-config",
MountPath: "/.kagent/.docker",
ReadOnly: true,
})
Comment on lines +1389 to +1435
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

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

imagePullSecrets entries with an empty Name (LocalObjectReference allows this) or repeated names will currently produce invalid SecretVolumeSource.SecretName values and/or duplicate volume names. It would be safer to validate that every reference has a non-empty name and to de-duplicate (or error) before creating volumes/mounts.

Suggested change
// Shared EmptyDir volume for the merged Docker config.
volumes = append(volumes, corev1.Volume{
Name: "kagent-docker-config",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
// Mount each imagePullSecret as a read-only directory under /docker-secrets/<name>.
authInitVolumeMounts := []corev1.VolumeMount{
{Name: "kagent-docker-config", MountPath: "/docker-config-out"},
}
for _, secret := range imagePullSecrets {
volName := "pull-secret-" + secret.Name
volumes = append(volumes, corev1.Volume{
Name: volName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secret.Name,
},
},
})
authInitVolumeMounts = append(authInitVolumeMounts, corev1.VolumeMount{
Name: volName,
MountPath: "/docker-secrets/" + secret.Name,
ReadOnly: true,
})
}
mergeScript := buildDockerAuthMergeScript(imagePullSecrets)
dockerAuthInitContainer := corev1.Container{
Name: "docker-auth-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", mergeScript},
VolumeMounts: authInitVolumeMounts,
}
containers = append(containers, dockerAuthInitContainer)
// Mount the merged config into skills-init so krane picks it up via DOCKER_CONFIG.
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: "kagent-docker-config",
MountPath: "/.kagent/.docker",
ReadOnly: true,
})
validImagePullSecrets := make([]corev1.LocalObjectReference, 0, len(imagePullSecrets))
seenImagePullSecrets := make(map[string]struct{}, len(imagePullSecrets))
for _, secret := range imagePullSecrets {
if secret.Name == "" {
continue
}
if _, seen := seenImagePullSecrets[secret.Name]; seen {
continue
}
seenImagePullSecrets[secret.Name] = struct{}{}
validImagePullSecrets = append(validImagePullSecrets, secret)
}
if len(validImagePullSecrets) > 0 {
// Shared EmptyDir volume for the merged Docker config.
volumes = append(volumes, corev1.Volume{
Name: "kagent-docker-config",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
// Mount each imagePullSecret as a read-only directory under /docker-secrets/<name>.
authInitVolumeMounts := []corev1.VolumeMount{
{Name: "kagent-docker-config", MountPath: "/docker-config-out"},
}
for _, secret := range validImagePullSecrets {
volName := "pull-secret-" + secret.Name
volumes = append(volumes, corev1.Volume{
Name: volName,
VolumeSource: corev1.VolumeSource{
Secret: &corev1.SecretVolumeSource{
SecretName: secret.Name,
},
},
})
authInitVolumeMounts = append(authInitVolumeMounts, corev1.VolumeMount{
Name: volName,
MountPath: "/docker-secrets/" + secret.Name,
ReadOnly: true,
})
}
mergeScript := buildDockerAuthMergeScript(validImagePullSecrets)
dockerAuthInitContainer := corev1.Container{
Name: "docker-auth-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", mergeScript},
VolumeMounts: authInitVolumeMounts,
}
containers = append(containers, dockerAuthInitContainer)
// Mount the merged config into skills-init so krane picks it up via DOCKER_CONFIG.
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: "kagent-docker-config",
MountPath: "/.kagent/.docker",
ReadOnly: true,
})
}

Copilot uses AI. Check for mistakes.
}

skillsInitContainer := corev1.Container{
Name: "skills-init",
Image: DefaultSkillsInitImageConfig.Image(),
Command: []string{"/bin/sh", "-c", script},
Expand All @@ -1377,7 +1445,31 @@ func buildSkillsInitContainer(
Resources: resources,
}

return container, volumes, nil
// If a merged Docker config is available, point krane to it via DOCKER_CONFIG.
if len(imagePullSecrets) > 0 {
skillsInitContainer.Env = append(skillsInitContainer.Env, corev1.EnvVar{
Name: "DOCKER_CONFIG",
Value: "/.kagent/.docker",
})
}

containers = append(containers, skillsInitContainer)
return containers, volumes, nil
}

// buildDockerAuthMergeScript renders the docker-auth-init shell script from the template.
// It merges the .auths sections from all kubernetes.io/dockerconfigjson secrets
// (mounted under /docker-secrets/<name>/) into a single config.json using jq.
func buildDockerAuthMergeScript(imagePullSecrets []corev1.LocalObjectReference) (string, error) {
names := make([]string, len(imagePullSecrets))
for i, s := range imagePullSecrets {
names[i] = s.Name
}
var buf bytes.Buffer
if err := dockerAuthInitTemplate.Execute(&buf, dockerAuthInitData{Secrets: names}); err != nil {
return "", fmt.Errorf("failed to render docker-auth-init script: %w", err)
}
return buf.String(), nil
}

func (a *adkApiTranslator) runPlugins(ctx context.Context, agent v1alpha2.AgentObject, outputs *AgentOutputs) error {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
set -e
mkdir -p /docker-config-out
merged='{"auths":{}}'
{{- range .Secrets }}
if [ -f /docker-secrets/{{ . }}/.dockerconfigjson ]; then
merged="$(printf '%s\n%s\n' "$merged" "$(cat /docker-secrets/{{ . }}/.dockerconfigjson)" | jq -s '.[0].auths * .[1].auths | {"auths": .}')"
fi
{{- end }}
printf '%s' "$merged" > /docker-config-out/config.json
Loading