diff --git a/docker/skills-init/Dockerfile b/docker/skills-init/Dockerfile index e884f34cf..dc89810f7 100644 --- a/docker/skills-init/Dockerfile +++ b/docker/skills-init/Dockerfile @@ -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 diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index a91a31b27..3ed715415 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -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: diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index 72dc42938..9de4adf06 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -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: diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index dfe8513d1..c0f3aa532 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -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, diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 47615e2d4..136058bce 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -1696,6 +1696,11 @@ func (in *SkillForAgent) DeepCopyInto(out *SkillForAgent) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]v1.LocalObjectReference, len(*in)) + copy(*out, *in) + } if in.GitAuthSecretRef != nil { in, out := &in.GitAuthSecretRef, &out.GitAuthSecretRef *out = new(v1.LocalObjectReference) 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 b11d030aa..0ee9528f8 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -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. @@ -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 { @@ -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/ 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, @@ -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 { @@ -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", @@ -1367,7 +1380,26 @@ func buildSkillsInitContainer( }) } - container = corev1.Container{ + // Mount each imagePullSecret directly into skills-init under /docker-secrets/. + // 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}, @@ -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 { diff --git a/go/core/internal/controller/translator/agent/git_skills_test.go b/go/core/internal/controller/translator/agent/git_skills_test.go index b5541140d..a1c49dfe7 100644 --- a/go/core/internal/controller/translator/agent/git_skills_test.go +++ b/go/core/internal/controller/translator/agent/git_skills_test.go @@ -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)) diff --git a/go/core/internal/controller/translator/agent/manifest_builder.go b/go/core/internal/controller/translator/agent/manifest_builder.go index 5a4e543fa..dcbaee9c4 100644 --- a/go/core/internal/controller/translator/agent/manifest_builder.go +++ b/go/core/internal/controller/translator/agent/manifest_builder.go @@ -386,13 +386,14 @@ func buildSkillsRuntime( manifestCtx.deployment.SecurityContext, initEnv, getDefaultResources(initResources), + spec.Skills.ImagePullSecrets, ) if err != nil { return nil, fmt.Errorf("failed to build skills init container: %w", err) } *volumes = append(*volumes, skillsVolumes...) - return []corev1.Container{container}, nil + return container, nil } func projectedTokenVolume() corev1.Volume { diff --git a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl index 4f0f3370e..4db51dd0d 100644 --- a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl +++ b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl @@ -1,4 +1,15 @@ set -e +{{- if .ImagePullSecrets }} +mkdir -p /tmp/kagent-docker-config +merged='{"auths":{}}' +{{- range .ImagePullSecrets }} +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" > /tmp/kagent-docker-config/config.json +export DOCKER_CONFIG=/tmp/kagent-docker-config +{{- end }} {{- if .AuthMountPath }} _auth_mount="$(cat <<'ENDVAL' {{ .AuthMountPath }} diff --git a/go/core/internal/controller/translator/agent/skills_unit_test.go b/go/core/internal/controller/translator/agent/skills_unit_test.go index d205ab957..6abc8c2dc 100644 --- a/go/core/internal/controller/translator/agent/skills_unit_test.go +++ b/go/core/internal/controller/translator/agent/skills_unit_test.go @@ -276,7 +276,7 @@ func Test_prepareSkillsInitData_duplicateNames(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := prepareSkillsInitData(tt.gitRefs, nil, tt.ociRefs, false) + _, err := prepareSkillsInitData(tt.gitRefs, nil, tt.ociRefs, false, nil) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -292,7 +292,7 @@ func Test_prepareSkillsInitData_pathTraversal(t *testing.T) { []v1alpha2.GitRepo{ {URL: "https://github.com/org/repo", Ref: "main", Path: "../escape"}, }, - nil, nil, false, + nil, nil, false, nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must not contain '..'") @@ -303,7 +303,7 @@ func Test_prepareSkillsInitData_absolutePath(t *testing.T) { []v1alpha2.GitRepo{ {URL: "https://github.com/org/repo", Ref: "main", Path: "/etc/passwd"}, }, - nil, nil, false, + nil, nil, false, nil, ) require.Error(t, err) assert.Contains(t, err.Error(), "must be relative") @@ -313,7 +313,7 @@ func Test_prepareSkillsInitData_authMountPath(t *testing.T) { data, err := prepareSkillsInitData( []v1alpha2.GitRepo{{URL: "https://github.com/org/repo", Ref: "main"}}, &corev1.LocalObjectReference{Name: "my-secret"}, - nil, false, + nil, false, nil, ) require.NoError(t, err) assert.Equal(t, "/git-auth", data.AuthMountPath) @@ -329,7 +329,7 @@ func Test_prepareSkillsInitData_sshHosts(t *testing.T) { }, &corev1.LocalObjectReference{Name: "ssh-secret"}, nil, - false, + false, nil, ) require.NoError(t, err) assert.Equal(t, []sshHostData{ @@ -346,7 +346,7 @@ func Test_prepareSkillsInitData_sshHostsDedupesDefaultPort(t *testing.T) { }, &corev1.LocalObjectReference{Name: "ssh-secret"}, nil, - false, + false, nil, ) require.NoError(t, err) assert.Equal(t, []sshHostData{ @@ -362,7 +362,7 @@ func Test_prepareSkillsInitData_noAuthSkipsSSHHosts(t *testing.T) { }, nil, // no auth secret nil, - false, + false, nil, ) require.NoError(t, err) assert.Empty(t, data.SSHHosts, "SSH hosts should not be collected when authSecretRef is nil") diff --git a/go/core/test/e2e/invoke_api_test.go b/go/core/test/e2e/invoke_api_test.go index b8de767d7..540f362ec 100644 --- a/go/core/test/e2e/invoke_api_test.go +++ b/go/core/test/e2e/invoke_api_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" k8s_runtime "k8s.io/apimachinery/pkg/runtime" @@ -83,6 +84,8 @@ func setupK8sClient(t *testing.T, includeV1Alpha1 bool) client.Client { } err = corev1.AddToScheme(scheme) require.NoError(t, err) + err = appsv1.AddToScheme(scheme) + require.NoError(t, err) cli, err := client.New(cfg, client.Options{ Scheme: scheme, @@ -1146,6 +1149,72 @@ func TestE2EInvokeSkillInAgent(t *testing.T) { runSyncTest(t, a2aClient, "make me a kebab", "Pick it up from around the corner", nil) } +func TestE2ESkillImagePullSecrets(t *testing.T) { + // Setup mock server + baseURL, stopServer := setupMockServer(t, "mocks/invoke_skill.json") + defer stopServer() + + // Setup Kubernetes client + cli := setupK8sClient(t, false) + + // Create a dummy dockerconfigjson secret. + // The kind-registry is unauthenticated, so credentials don't matter — + // we're testing that the controller embeds the credential merge logic into skills-init. + dockerConfigJSON := `{"auths":{"kind-registry:5000":{"username":"user","password":"pass","auth":"dXNlcjpwYXNz"}}}` + pullSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-pull-secret-", + Namespace: "kagent", + }, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{ + corev1.DockerConfigJsonKey: []byte(dockerConfigJSON), + }, + } + require.NoError(t, cli.Create(t.Context(), pullSecret)) + cleanup(t, cli, pullSecret) + + // Setup model config and agent with imagePullSecrets + modelCfg := setupModelConfig(t, cli, baseURL) + agent := setupAgentWithOptions(t, cli, modelCfg.Name, nil, AgentOptions{ + Skills: &v1alpha2.SkillForAgent{ + InsecureSkipVerify: true, + Refs: []string{"kind-registry:5000/kebab-maker:latest"}, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: pullSecret.Name}, + }, + }, + }) + + // Verify the Deployment has exactly one init container: skills-init (credential merge is embedded in its script) + deployment := &appsv1.Deployment{} + require.NoError(t, cli.Get(t.Context(), client.ObjectKey{Name: agent.Name, Namespace: agent.Namespace}, deployment)) + initContainers := deployment.Spec.Template.Spec.InitContainers + require.Len(t, initContainers, 1, "expected exactly one init container: skills-init") + require.Equal(t, "skills-init", initContainers[0].Name) + + // Verify skills-init mounts the pull secret volume and its script contains the merge logic + skillsInit := initContainers[0] + var foundSecretMount bool + for _, vm := range skillsInit.VolumeMounts { + if strings.Contains(vm.Name, "pull-secret") { + foundSecretMount = true + break + } + } + require.True(t, foundSecretMount, "skills-init should mount the pull secret volume") + + require.Len(t, skillsInit.Command, 3) + script := skillsInit.Command[2] + require.Contains(t, script, "jq", "skills-init script should contain jq for credential merge") + require.Contains(t, script, ".dockerconfigjson", "skills-init script should reference .dockerconfigjson") + require.Contains(t, script, "/tmp/kagent-docker-config", "skills-init script should write merged config to /tmp") + + // Verify the agent works end-to-end with the skill + a2aClient := setupA2AClient(t, agent) + runSyncTest(t, a2aClient, "make me a kebab", "Pick it up from around the corner", nil) +} + func TestE2EDeclarativeAgentNetworkAllowlistWithSkills(t *testing.T) { runDeclarativeAgentNetworkAllowlistWithSkills(t, "python", nil) } diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index a91a31b27..3ed715415 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -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: diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index 72dc42938..9de4adf06 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -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: