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 @@ -13316,6 +13316,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 @@ -10967,6 +10967,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 @@ -98,6 +98,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.

Original file line number Diff line number Diff line change
Expand Up @@ -1180,11 +1180,12 @@ func validateSubPath(p string) error {

// skillsInitData holds the template data for the unified skills-init script.
type skillsInitData struct {
AuthMountPath string // "/git-auth" or "" (for git auth)
GitRefs []gitRefData // git repos to clone
OCIRefs []ociRefData // OCI images to pull
InsecureOCI bool // --insecure flag for krane
SSHHosts []sshHostData // extra hosts to add to known_hosts via ssh-keyscan
AuthMountPath string // "/git-auth" or "" (for git auth)
GitRefs []gitRefData // git repos to clone
OCIRefs []ociRefData // OCI images to pull
InsecureOCI bool // --insecure flag for krane
SSHHosts []sshHostData // extra hosts to add to known_hosts via ssh-keyscan
ImagePullSecrets []string // secret names whose .dockerconfigjson are merged by the script
}

// sshHostData holds the host and optional port for an SSH known_hosts entry.
Expand Down Expand Up @@ -1247,9 +1248,11 @@ func prepareSkillsInitData(
authSecretRef *corev1.LocalObjectReference,
ociRefs []string,
insecureOCI bool,
imagePullSecrets []string,
) (skillsInitData, error) {
data := skillsInitData{
InsecureOCI: insecureOCI,
InsecureOCI: insecureOCI,
ImagePullSecrets: imagePullSecrets,
}

if authSecretRef != nil {
Expand Down Expand Up @@ -1324,6 +1327,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, each kubernetes.io/dockerconfigjson secret is mounted
// under /docker-secrets/<name> and the script merges them into a single config.json in /tmp;
// krane reads the credentials via the DOCKER_CONFIG env var exported by the script.
func buildSkillsInitContainer(
gitRefs []v1alpha2.GitRepo,
authSecretRef *corev1.LocalObjectReference,
Expand All @@ -1332,14 +1338,21 @@ func buildSkillsInitContainer(
securityContext *corev1.SecurityContext,
env []corev1.EnvVar,
resources corev1.ResourceRequirements,
) (container corev1.Container, volumes []corev1.Volume, err error) {
data, err := prepareSkillsInitData(gitRefs, authSecretRef, ociRefs, insecureOCI)
imagePullSecrets []corev1.LocalObjectReference,
) (containers []corev1.Container, volumes []corev1.Volume, err error) {
// Collect secret names for the script template.
pullSecretNames := make([]string, len(imagePullSecrets))
for i, s := range imagePullSecrets {
pullSecretNames[i] = s.Name
}

data, err := prepareSkillsInitData(gitRefs, authSecretRef, ociRefs, insecureOCI, pullSecretNames)
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 All @@ -1350,7 +1363,7 @@ func buildSkillsInitContainer(
{Name: "kagent-skills", MountPath: "/skills"},
}

// Mount single auth secret if provided
// Mount single auth secret if provided.
if authSecretRef != nil {
volumes = append(volumes, corev1.Volume{
Name: "git-auth",
Expand All @@ -1367,7 +1380,26 @@ func buildSkillsInitContainer(
})
}

container = corev1.Container{
// Mount each imagePullSecret directly into skills-init under /docker-secrets/<name>.
// The script merges them into /tmp/kagent-docker-config/config.json and exports DOCKER_CONFIG.
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,
},
},
})
volumeMounts = append(volumeMounts, corev1.VolumeMount{
Name: volName,
MountPath: "/docker-secrets/" + secret.Name,
ReadOnly: true,
})
}

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

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

func (a *adkApiTranslator) runPlugins(ctx context.Context, agent v1alpha2.AgentObject, outputs *AgentOutputs) error {
Expand Down
175 changes: 175 additions & 0 deletions go/core/internal/controller/translator/agent/git_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,181 @@ func Test_AdkApiTranslator_Skills(t *testing.T) {
}
}

func Test_AdkApiTranslator_SkillsImagePullSecrets(t *testing.T) {
scheme := schemev1.Scheme
require.NoError(t, v1alpha2.AddToScheme(scheme))

namespace := "default"
modelName := "test-model"

modelConfig := &v1alpha2.ModelConfig{
ObjectMeta: metav1.ObjectMeta{
Name: modelName,
Namespace: namespace,
},
Spec: v1alpha2.ModelConfigSpec{
Model: "gpt-4",
Provider: v1alpha2.ModelProviderOpenAI,
},
}

defaultModel := types.NamespacedName{
Namespace: namespace,
Name: modelName,
}

tests := []struct {
name string
agent *v1alpha2.Agent
wantImagePullSecret bool
}{
{
name: "OCI skills without imagePullSecrets - single init container, no credential merge",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "agent-no-pull-secret", Namespace: namespace},
Spec: v1alpha2.AgentSpec{
Type: v1alpha2.AgentType_Declarative,
Declarative: &v1alpha2.DeclarativeAgentSpec{
SystemMessage: "test",
ModelConfig: modelName,
},
Skills: &v1alpha2.SkillForAgent{
Refs: []string{"ghcr.io/org/skill:v1"},
},
},
},
wantImagePullSecret: false,
},
{
name: "OCI skills with single imagePullSecret - credential merge in skills-init script",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "agent-one-pull-secret", Namespace: namespace},
Spec: v1alpha2.AgentSpec{
Type: v1alpha2.AgentType_Declarative,
Declarative: &v1alpha2.DeclarativeAgentSpec{
SystemMessage: "test",
ModelConfig: modelName,
},
Skills: &v1alpha2.SkillForAgent{
Refs: []string{"docker.artifactory.example.com/org/skill:v1"},
ImagePullSecrets: []corev1.LocalObjectReference{{Name: "registry-credentials"}},
},
},
},
wantImagePullSecret: true,
},
{
name: "OCI skills with multiple imagePullSecrets - all auths merged in skills-init script",
agent: &v1alpha2.Agent{
ObjectMeta: metav1.ObjectMeta{Name: "agent-multi-pull-secrets", Namespace: namespace},
Spec: v1alpha2.AgentSpec{
Type: v1alpha2.AgentType_Declarative,
Declarative: &v1alpha2.DeclarativeAgentSpec{
SystemMessage: "test",
ModelConfig: modelName,
},
Skills: &v1alpha2.SkillForAgent{
Refs: []string{
"docker.artifactory.example.com/org/skill-a:v1",
"acr.azurecr.io/org/skill-b:v2",
},
ImagePullSecrets: []corev1.LocalObjectReference{
{Name: "artifactory-creds"},
{Name: "acr-creds"},
},
},
},
},
wantImagePullSecret: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
kubeClient := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(modelConfig, tt.agent).
Build()

trans := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", nil)

outputs, err := translator.TranslateAgent(context.Background(), trans, tt.agent)
require.NoError(t, err)
require.NotNil(t, outputs)

var deployment *appsv1.Deployment
for _, obj := range outputs.Manifest {
if d, ok := obj.(*appsv1.Deployment); ok {
deployment = d
}
}
require.NotNil(t, deployment, "Deployment should be created")

initContainers := deployment.Spec.Template.Spec.InitContainers

// Always exactly one init container regardless of imagePullSecrets.
assert.Len(t, initContainers, 1, "should always have exactly one init container (skills-init)")
require.Equal(t, "skills-init", initContainers[0].Name, "the single init container must be skills-init")

skillsInitContainer := &initContainers[0]
require.Len(t, skillsInitContainer.Command, 3)
script := skillsInitContainer.Command[2]

// No docker-auth-init container should ever exist.
for _, c := range initContainers {
assert.NotEqual(t, "docker-auth-init", c.Name, "docker-auth-init container must not exist")
}
// No EmptyDir docker-config volume should exist.
for _, v := range deployment.Spec.Template.Spec.Volumes {
assert.NotEqual(t, "kagent-docker-config", v.Name, "kagent-docker-config EmptyDir volume must not exist")
}

if tt.wantImagePullSecret {
// Script must contain the credential merge logic.
assert.Contains(t, script, "jq")
assert.Contains(t, script, ".dockerconfigjson")
assert.Contains(t, script, "/tmp/kagent-docker-config/config.json")
assert.Contains(t, script, "export DOCKER_CONFIG=/tmp/kagent-docker-config")

require.NotNil(t, tt.agent.Spec.Skills)
for _, ps := range tt.agent.Spec.Skills.ImagePullSecrets {
volName := "pull-secret-" + ps.Name

// Secret volume on the pod.
hasPullSecretVol := false
for _, v := range deployment.Spec.Template.Spec.Volumes {
if v.Name == volName && v.Secret != nil && v.Secret.SecretName == ps.Name {
hasPullSecretVol = true
}
}
assert.True(t, hasPullSecretVol, "pull-secret volume %q should exist on the pod", volName)

// Volume mounted on skills-init.
hasPullSecretMount := false
for _, vm := range skillsInitContainer.VolumeMounts {
if vm.Name == volName && vm.MountPath == "/docker-secrets/"+ps.Name && vm.ReadOnly {
hasPullSecretMount = true
}
}
assert.True(t, hasPullSecretMount, "skills-init should mount pull-secret %q at /docker-secrets/%s", volName, ps.Name)

// Script references each secret by name.
assert.Contains(t, script, "/docker-secrets/"+ps.Name+"/.dockerconfigjson")
}
} else {
// No credential merge logic in the script.
assert.NotContains(t, script, "DOCKER_CONFIG")
assert.NotContains(t, script, "kagent-docker-config")
// No pull-secret volumes.
for _, v := range deployment.Spec.Template.Spec.Volumes {
assert.False(t, len(v.Name) > len("pull-secret-") && v.Name[:len("pull-secret-")] == "pull-secret-",
"no pull-secret volumes should exist without imagePullSecrets")
}
}
})
}
}

func Test_AdkApiTranslator_SkillsConfigurableImage(t *testing.T) {
scheme := schemev1.Scheme
require.NoError(t, v1alpha2.AddToScheme(scheme))
Expand Down
Loading