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 fb4b2ce16..d14de599c 100644 --- a/go/core/internal/controller/translator/agent/adk_api_translator.go +++ b/go/core/internal/controller/translator/agent/adk_api_translator.go @@ -1584,10 +1584,17 @@ 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 + 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 +} + +// sshHostData holds the host and optional port for an SSH known_hosts entry. +type sshHostData struct { + Host string // hostname or IP + Port string // port number, empty means default (22) } // gitRefData holds pre-computed fields for each git skill ref, used by the script template. @@ -1651,6 +1658,32 @@ func prepareSkillsInitData( if authSecretRef != nil { data.AuthMountPath = "/git-auth" + seenHosts := make(map[string]bool) + hostPattern := regexp.MustCompile(`^[A-Za-z0-9\.\-:]+$`) + portPattern := regexp.MustCompile(`^[0-9]+$`) + for _, ref := range gitRefs { + u, err := url.Parse(ref.URL) + if err != nil || u.Scheme != "ssh" { + continue + } + host := u.Hostname() + if host == "" || !hostPattern.MatchString(host) { + continue + } + port := u.Port() + if port == "22" { + port = "" // 22 is the SSH default; omit to avoid -p flag + } + if port != "" && !portPattern.MatchString(port) { + continue + } + key := host + ":" + port + if seenHosts[key] { + continue + } + seenHosts[key] = true + data.SSHHosts = append(data.SSHHosts, sshHostData{Host: host, Port: port}) + } } seen := make(map[string]bool) 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 1d29e6c5b..a9205f87a 100644 --- a/go/core/internal/controller/translator/agent/git_skills_test.go +++ b/go/core/internal/controller/translator/agent/git_skills_test.go @@ -2,6 +2,7 @@ package agent_test import ( "context" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -44,13 +45,14 @@ func Test_AdkApiTranslator_Skills(t *testing.T) { name string agent *v1alpha2.Agent // assertions - wantSkillsInit bool - wantSkillsVolume bool - wantContainsBranch string - wantContainsCommit string - wantContainsPath string - wantContainsKrane bool - wantAuthVolume bool + wantSkillsInit bool + wantSkillsVolume bool + wantContainsBranch string + wantContainsCommit string + wantContainsPath string + wantContainsKrane bool + wantAuthVolume bool + wantSSHKeyscanHosts []string // substrings expected in the ssh-keyscan lines }{ { name: "no skills - no init containers", @@ -215,6 +217,34 @@ func Test_AdkApiTranslator_Skills(t *testing.T) { wantSkillsVolume: true, wantAuthVolume: true, }, + { + name: "git skills with SSH URL and auth secret scans custom host", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-ssh", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitAuthSecretRef: &corev1.LocalObjectReference{ + Name: "gitea-ssh-credentials", + }, + GitRefs: []v1alpha2.GitRepo{ + { + URL: "ssh://git@gitea-ssh.gitea:22/gitops/ssh-skills-repo.git", + Ref: "main", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantAuthVolume: true, + wantSSHKeyscanHosts: []string{"gitea-ssh.gitea"}, + }, { name: "git skill with custom name", agent: &v1alpha2.Agent{ @@ -358,7 +388,7 @@ func Test_AdkApiTranslator_Skills(t *testing.T) { for _, v := range deployment.Spec.Template.Spec.Volumes { if v.Secret != nil && v.Name == "git-auth" { hasAuthVolume = true - assert.Equal(t, "github-token", v.Secret.SecretName, "auth volume should reference the correct secret") + assert.Equal(t, tt.agent.Spec.Skills.GitAuthSecretRef.Name, v.Secret.SecretName, "auth volume should reference the correct secret") } } assert.True(t, hasAuthVolume, "git-auth volume should exist") @@ -378,6 +408,16 @@ func Test_AdkApiTranslator_Skills(t *testing.T) { assert.Contains(t, script, "credential.helper") } + // Verify custom SSH hosts are scanned + if len(tt.wantSSHKeyscanHosts) > 0 { + require.NotNil(t, skillsInitContainer) + script := skillsInitContainer.Command[2] + for _, host := range tt.wantSSHKeyscanHosts { + expected := fmt.Sprintf("ssh-keyscan %s", host) + assert.Contains(t, script, expected, "script should ssh-keyscan custom host %q", host) + } + } + // Verify insecure flag for OCI skills if tt.agent.Spec.Skills != nil && tt.agent.Spec.Skills.InsecureSkipVerify { require.NotNil(t, skillsInitContainer) 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 3226e1bc0..9cac78b5f 100644 --- a/go/core/internal/controller/translator/agent/skills-init.sh.tmpl +++ b/go/core/internal/controller/translator/agent/skills-init.sh.tmpl @@ -9,6 +9,13 @@ if [ -f "${_auth_mount}/ssh-privatekey" ]; then cp "${_auth_mount}/ssh-privatekey" ~/.ssh/id_rsa chmod 600 ~/.ssh/id_rsa ssh-keyscan github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts +{{- range .SSHHosts }} + {{- if .Port }} + ssh-keyscan -p {{ .Port }} {{ .Host }} >> ~/.ssh/known_hosts + {{- else }} + ssh-keyscan {{ .Host }} >> ~/.ssh/known_hosts + {{- end }} +{{- end }} elif [ -f "${_auth_mount}/token" ]; then git config --global credential.helper "!f() { echo username=x-access-token; echo password=\$(cat ${_auth_mount}/token); }; f" fi