diff --git a/.gitignore b/.gitignore index 18e726076..6b928ec5e 100644 --- a/.gitignore +++ b/.gitignore @@ -156,7 +156,7 @@ celerybeat.pid # Environments python/.env .venv -env/ +.env/ venv/ ENV/ env.bak/ diff --git a/Makefile b/Makefile index 2ca008f98..771f2b6ad 100644 --- a/Makefile +++ b/Makefile @@ -35,16 +35,19 @@ CONTROLLER_IMAGE_NAME ?= controller UI_IMAGE_NAME ?= ui APP_IMAGE_NAME ?= app KAGENT_ADK_IMAGE_NAME ?= kagent-adk +SKILLS_INIT_IMAGE_NAME ?= skills-init CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) APP_IMAGE_TAG ?= $(VERSION) KAGENT_ADK_IMAGE_TAG ?= $(VERSION) +SKILLS_INIT_IMAGE_TAG ?= $(VERSION) CONTROLLER_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(CONTROLLER_IMAGE_NAME):$(CONTROLLER_IMAGE_TAG) UI_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(UI_IMAGE_NAME):$(UI_IMAGE_TAG) APP_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(APP_IMAGE_NAME):$(APP_IMAGE_TAG) KAGENT_ADK_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(KAGENT_ADK_IMAGE_NAME):$(KAGENT_ADK_IMAGE_TAG) +SKILLS_INIT_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SKILLS_INIT_IMAGE_NAME):$(SKILLS_INIT_IMAGE_TAG) #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -211,13 +214,13 @@ prune-docker-images: docker images --filter dangling=true -q | xargs -r docker rmi || : .PHONY: build -build: buildx-create build-controller build-ui build-app +build: buildx-create build-controller build-ui build-app build-skills-init @echo "Build completed successfully." @echo "Controller Image: $(CONTROLLER_IMG)" @echo "UI Image: $(UI_IMG)" @echo "App Image: $(APP_IMG)" @echo "Kagent ADK Image: $(KAGENT_ADK_IMG)" - @echo "Tools Image: $(TOOLS_IMG)" + @echo "Skills Init Image: $(SKILLS_INIT_IMG)" .PHONY: build-monitor build-monitor: buildx-create @@ -244,9 +247,6 @@ lint: make -C go lint make -C python lint -.PHONY: push -push: push-controller push-ui push-app push-kagent-adk - .PHONY: controller-manifests controller-manifests: make -C go manifests @@ -268,6 +268,10 @@ build-kagent-adk: buildx-create build-app: buildx-create build-kagent-adk $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --build-arg KAGENT_ADK_VERSION=$(KAGENT_ADK_IMAGE_TAG) --build-arg DOCKER_REGISTRY=$(DOCKER_REGISTRY) -t $(APP_IMG) -f python/Dockerfile.app ./python +.PHONY: build-skills-init +build-skills-init: buildx-create + $(DOCKER_BUILDER) build $(DOCKER_BUILD_ARGS) -t $(SKILLS_INIT_IMG) -f docker/skills-init/Dockerfile docker/skills-init + .PHONY: helm-cleanup helm-cleanup: rm -f ./$(HELM_DIST_FOLDER)/*.tgz diff --git a/docker/skills-init/Dockerfile b/docker/skills-init/Dockerfile new file mode 100644 index 000000000..ca0ff825c --- /dev/null +++ b/docker/skills-init/Dockerfile @@ -0,0 +1,18 @@ +### Stage 0: build krane +FROM golang:1.25-alpine AS krane-builder + +ENV KRANE_VERSION=v0.20.7 +WORKDIR /build + +RUN apk add --no-cache git && \ + git clone --depth 1 --branch $KRANE_VERSION \ + https://github.com/google/go-containerregistry.git + +WORKDIR /build/go-containerregistry/cmd/krane + +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/krane . + +FROM alpine:3.21 + +RUN apk add --no-cache git +COPY --from=krane-builder /build/krane /usr/local/bin/krane diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 5dc8e4958..79dc6f18b 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -69,6 +69,7 @@ type AgentSpec struct { AllowedNamespaces *AllowedNamespaces `json:"allowedNamespaces,omitempty"` } +// +kubebuilder:validation:AtLeastOneOf=refs,gitRefs type SkillForAgent struct { // Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). // Meant for development and testing purposes only. @@ -76,9 +77,43 @@ type SkillForAgent struct { InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` // The list of skill images to fetch. - // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=20 + // +kubebuilder:validation:MinItems=1 + // +optional Refs []string `json:"refs,omitempty"` + + // Reference to a Secret containing git credentials. + // Applied to all gitRefs entries. + // The secret should contain a `token` key for HTTPS auth, + // or `ssh-privatekey` for SSH auth. + // +optional + GitAuthSecretRef *corev1.LocalObjectReference `json:"gitAuthSecretRef,omitempty"` + + // Git repositories to fetch skills from. + // +kubebuilder:validation:MaxItems=20 + // +kubebuilder:validation:MinItems=1 + // +optional + GitRefs []GitRepo `json:"gitRefs,omitempty"` +} + +// GitRepo specifies a single Git repository to fetch skills from. +type GitRepo struct { + // URL of the git repository (HTTPS or SSH). + // +kubebuilder:validation:Required + URL string `json:"url"` + + // Git reference: branch name, tag, or commit SHA. + // +optional + // +kubebuilder:default="main" + Ref string `json:"ref,omitempty"` + + // Subdirectory within the repo to use as the skill root. + // +optional + Path string `json:"path,omitempty"` + + // Name for the skill directory under /skills. Defaults to the repo name. + // +optional + Name string `json:"name,omitempty"` } // +kubebuilder:validation:XValidation:rule="!has(self.systemMessage) || !has(self.systemMessageFrom)",message="systemMessage and systemMessageFrom are mutually exclusive" diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index b6cf6156f..e275e3e19 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -449,6 +449,21 @@ func (in *GeminiVertexAIConfig) DeepCopy() *GeminiVertexAIConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitRepo) DeepCopyInto(out *GitRepo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitRepo. +func (in *GitRepo) DeepCopy() *GitRepo { + if in == nil { + return nil + } + out := new(GitRepo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MCPTool) DeepCopyInto(out *MCPTool) { *out = *in @@ -1095,6 +1110,16 @@ func (in *SkillForAgent) DeepCopyInto(out *SkillForAgent) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.GitAuthSecretRef != nil { + in, out := &in.GitAuthSecretRef, &out.GitAuthSecretRef + *out = new(v1.LocalObjectReference) + **out = **in + } + if in.GitRefs != nil { + in, out := &in.GitRefs, &out.GitRefs + *out = make([]GitRepo, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SkillForAgent. diff --git a/go/config/crd/bases/kagent.dev_agents.yaml b/go/config/crd/bases/kagent.dev_agents.yaml index 418225433..f2e060572 100644 --- a/go/config/crd/bases/kagent.dev_agents.yaml +++ b/go/config/crd/bases/kagent.dev_agents.yaml @@ -10027,6 +10027,52 @@ spec: Skills to load into the agent. They will be pulled from the specified container images. and made available to the agent under the `/skills` folder. properties: + gitAuthSecretRef: + description: |- + Reference to a Secret containing git credentials. + Applied to all gitRefs entries. + The secret should contain a `token` key for HTTPS auth, + or `ssh-privatekey` for SSH auth. + 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 + gitRefs: + description: Git repositories to fetch skills from. + items: + description: GitRepo specifies a single Git repository to fetch + skills from. + properties: + name: + description: Name for the skill directory under /skills. + Defaults to the repo name. + type: string + path: + description: Subdirectory within the repo to use as the + skill root. + type: string + ref: + default: main + description: 'Git reference: branch name, tag, or commit + SHA.' + type: string + url: + description: URL of the git repository (HTTPS or SSH). + type: string + required: + - url + type: object + maxItems: 20 + minItems: 1 + type: array insecureSkipVerify: description: |- Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 7e2af4433..a5dce3a30 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -1,8 +1,10 @@ package agent import ( + "bytes" "context" "crypto/sha256" + _ "embed" "encoding/binary" "encoding/hex" "encoding/json" @@ -11,8 +13,11 @@ import ( "maps" "net/url" "os" + "path" + "regexp" "slices" "strings" + "text/template" "time" "github.com/kagent-dev/kagent/go/api/v1alpha2" @@ -80,6 +85,11 @@ type ImageConfig struct { Repository string `json:"repository,omitempty"` } +// Image returns the fully qualified image reference (registry/repository:tag). +func (c ImageConfig) Image() string { + return fmt.Sprintf("%s/%s:%s", c.Registry, c.Repository, c.Tag) +} + var DefaultImageConfig = ImageConfig{ Registry: "cr.kagent.dev", Tag: version.Get().Version, @@ -88,6 +98,15 @@ var DefaultImageConfig = ImageConfig{ Repository: "kagent-dev/kagent/app", } +// DefaultSkillsInitImageConfig is the image config for the skills-init container +// that clones skill repositories from Git and pulls OCI skill images. +var DefaultSkillsInitImageConfig = ImageConfig{ + Registry: "cr.kagent.dev", + Tag: version.Get().Version, + PullPolicy: string(corev1.PullIfNotPresent), + Repository: "kagent-dev/kagent/skills-init", +} + // TODO(ilackarms): migrate this whole package to pkg/translator type AgentOutputs = translator.AgentOutputs @@ -371,9 +390,14 @@ func (a *adkApiTranslator) buildManifest( ) var skills []string - if agent.Spec.Skills != nil && len(agent.Spec.Skills.Refs) != 0 { + var gitRefs []v1alpha2.GitRepo + var gitAuthSecretRef *corev1.LocalObjectReference + if agent.Spec.Skills != nil { skills = agent.Spec.Skills.Refs + gitRefs = agent.Spec.Skills.GitRefs + gitAuthSecretRef = agent.Spec.Skills.GitAuthSecretRef } + hasSkills := len(skills) > 0 || len(gitRefs) > 0 // Build Deployment volumes := append(secretVol, dep.Volumes...) @@ -382,32 +406,13 @@ func (a *adkApiTranslator) buildManifest( var initContainers []corev1.Container - if len(skills) > 0 { + // Add shared skills volume and env var when any skills (OCI or git) are present + if hasSkills { skillsEnv := corev1.EnvVar{ Name: env.KagentSkillsFolder.Name(), Value: "/skills", } needSandbox = true - insecure := agent.Spec.Skills.InsecureSkipVerify - command := []string{"kagent-adk", "pull-skills"} - if insecure { - command = append(command, "--insecure") - } - initContainerSecurityContext := dep.SecurityContext - if initContainerSecurityContext != nil { - initContainerSecurityContext = initContainerSecurityContext.DeepCopy() - } - initContainers = append(initContainers, corev1.Container{ - Name: "skills-init", - Image: dep.Image, - Command: command, - Args: skills, - VolumeMounts: []corev1.VolumeMount{ - {Name: "kagent-skills", MountPath: "/skills"}, - }, - Env: []corev1.EnvVar{skillsEnv}, - SecurityContext: initContainerSecurityContext, - }) volumes = append(volumes, corev1.Volume{ Name: "kagent-skills", VolumeSource: corev1.VolumeSource{ @@ -420,6 +425,14 @@ func (a *adkApiTranslator) buildManifest( ReadOnly: true, }) sharedEnv = append(sharedEnv, skillsEnv) + + insecure := agent.Spec.Skills != nil && agent.Spec.Skills.InsecureSkipVerify + container, skillsVolumes, err := buildSkillsInitContainer(gitRefs, gitAuthSecretRef, skills, insecure, dep.SecurityContext) + if err != nil { + return nil, fmt.Errorf("failed to build skills init container: %w", err) + } + initContainers = append(initContainers, container) + volumes = append(volumes, skillsVolumes...) } // Token volume @@ -1345,6 +1358,220 @@ func collectOtelEnvFromProcess() []corev1.EnvVar { return envVars } +// isCommitSHA returns true if ref looks like a full 40-character hex commit SHA. +var commitSHARegex = regexp.MustCompile(`^[0-9a-fA-F]{40}$`) + +func isCommitSHA(ref string) bool { + return commitSHARegex.MatchString(ref) +} + +// gitSkillName returns the directory name for a git skill ref. +// If Name is set, it is used; otherwise the last path segment of the repo URL +// (with any .git suffix stripped) is used. +// Query parameters and fragments are stripped before extracting the base name. +func gitSkillName(ref v1alpha2.GitRepo) string { + if ref.Name != "" { + return ref.Name + } + // Parse the URL to strip query params and fragments + u := ref.URL + if parsed, err := url.Parse(u); err == nil { + u = parsed.Path + // If the path is empty (e.g. just a host), fall back to the raw URL + if u == "" { + u = ref.URL + } + } + u = strings.TrimSuffix(u, ".git") + return path.Base(u) +} + +// validateSubPath rejects subPath values that are absolute or contain ".." traversal segments. +func validateSubPath(p string) error { + if p == "" { + return nil + } + if path.IsAbs(p) { + return fmt.Errorf("skill subPath must be relative, got %q", p) + } + if slices.Contains(strings.Split(p, "/"), "..") { + return fmt.Errorf("skill subPath must not contain '..', got %q", p) + } + return nil +} + +// 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 +} + +// gitRefData holds pre-computed fields for each git skill ref, used by the script template. +type gitRefData struct { + URL string + Ref string + Dest string // e.g. /skills/my-skill + IsCommit bool // true if Ref is a 40-char hex SHA + SubPath string // Path with trailing slash stripped +} + +// ociRefData holds pre-computed fields for each OCI skill ref, used by the script template. +type ociRefData struct { + Image string // full image ref e.g. ghcr.io/org/skill:v1 + Dest string // /skills/ +} + +//go:embed skills-init.sh.tmpl +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)) + +// buildSkillsScript renders the unified skills-init shell script. +func buildSkillsScript(data skillsInitData) (string, error) { + var buf bytes.Buffer + if err := skillsScriptTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("failed to render skills init script: %w", err) + } + return buf.String(), nil +} + +// ociSkillName extracts a skill directory name from an OCI image reference. +// It takes the last path component of the repo (stripped of tag/digest). +func ociSkillName(imageRef string) string { + ref := imageRef + // Strip digest + if i := strings.LastIndex(ref, "@"); i != -1 { + ref = ref[:i] + } + // Strip tag (colon after the last slash is a tag, not a port) + if i := strings.LastIndex(ref, ":"); i != -1 { + if j := strings.LastIndex(ref, "/"); i > j { + ref = ref[:i] + } + } + return path.Base(ref) +} + +// prepareSkillsInitData converts CRD values to the template-ready data struct. +// It validates subPaths and detects duplicate skill directory names. +func prepareSkillsInitData( + gitRefs []v1alpha2.GitRepo, + authSecretRef *corev1.LocalObjectReference, + ociRefs []string, + insecureOCI bool, +) (skillsInitData, error) { + data := skillsInitData{ + InsecureOCI: insecureOCI, + } + + if authSecretRef != nil { + data.AuthMountPath = "/git-auth" + } + + seen := make(map[string]bool) + + for _, ref := range gitRefs { + subPath := strings.TrimSuffix(ref.Path, "/") + if err := validateSubPath(subPath); err != nil { + return skillsInitData{}, err + } + + gitRef := ref.Ref + if gitRef == "" { + gitRef = "main" + } + ref.Ref = gitRef + + name := gitSkillName(ref) + if seen[name] { + return skillsInitData{}, fmt.Errorf("duplicate skill directory name %q", name) + } + seen[name] = true + + data.GitRefs = append(data.GitRefs, gitRefData{ + URL: ref.URL, + Ref: gitRef, + Dest: "/skills/" + name, + IsCommit: isCommitSHA(gitRef), + SubPath: subPath, + }) + } + + for _, imageRef := range ociRefs { + name := ociSkillName(imageRef) + if seen[name] { + return skillsInitData{}, fmt.Errorf("duplicate skill directory name %q", name) + } + seen[name] = true + + data.OCIRefs = append(data.OCIRefs, ociRefData{ + Image: imageRef, + Dest: "/skills/" + name, + }) + } + + return data, nil +} + +// 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. +func buildSkillsInitContainer( + gitRefs []v1alpha2.GitRepo, + authSecretRef *corev1.LocalObjectReference, + ociRefs []string, + insecureOCI bool, + securityContext *corev1.SecurityContext, +) (container corev1.Container, volumes []corev1.Volume, err error) { + data, err := prepareSkillsInitData(gitRefs, authSecretRef, ociRefs, insecureOCI) + if err != nil { + return corev1.Container{}, nil, err + } + script, err := buildSkillsScript(data) + if err != nil { + return corev1.Container{}, nil, err + } + + initSecCtx := securityContext + if initSecCtx != nil { + initSecCtx = initSecCtx.DeepCopy() + } + + volumeMounts := []corev1.VolumeMount{ + {Name: "kagent-skills", MountPath: "/skills"}, + } + + // Mount single auth secret if provided + if authSecretRef != nil { + volumes = append(volumes, corev1.Volume{ + Name: "git-auth", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: authSecretRef.Name, + }, + }, + }) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: "git-auth", + MountPath: "/git-auth", + ReadOnly: true, + }) + } + + container = corev1.Container{ + Name: "skills-init", + Image: DefaultSkillsInitImageConfig.Image(), + Command: []string{"/bin/sh", "-c", script}, + VolumeMounts: volumeMounts, + SecurityContext: initSecCtx, + } + + return container, volumes, nil +} + func (a *adkApiTranslator) runPlugins(ctx context.Context, agent *v1alpha2.Agent, outputs *AgentOutputs) error { var errs error for _, plugin := range a.plugins { diff --git a/go/internal/controller/translator/agent/adk_translator_golden_test.go b/go/internal/controller/translator/agent/adk_translator_golden_test.go index 562f547f3..e7d05a0c2 100644 --- a/go/internal/controller/translator/agent/adk_translator_golden_test.go +++ b/go/internal/controller/translator/agent/adk_translator_golden_test.go @@ -37,6 +37,16 @@ type TestInput struct { // TestGoldenAdkTranslator runs golden tests for the ADK API translator func TestGoldenAdkTranslator(t *testing.T) { + // Clear all OTEL_ env vars so host environment doesn't leak into + // golden outputs via collectOtelEnvFromProcess(). + for _, env := range os.Environ() { + if strings.HasPrefix(env, "OTEL_") { + key, _, _ := strings.Cut(env, "=") + t.Setenv(key, "") + os.Unsetenv(key) + } + } + // Skip if running in CI without update flag updateGolden := os.Getenv("UPDATE_GOLDEN") == "true" diff --git a/go/internal/controller/translator/agent/git_skills_test.go b/go/internal/controller/translator/agent/git_skills_test.go new file mode 100644 index 000000000..6bc9a6bb2 --- /dev/null +++ b/go/internal/controller/translator/agent/git_skills_test.go @@ -0,0 +1,467 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + translator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + schemev1 "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func Test_AdkApiTranslator_Skills(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 + // assertions + wantSkillsInit bool + wantSkillsVolume bool + wantContainsBranch string + wantContainsCommit string + wantContainsPath string + wantContainsKrane bool + wantAuthVolume bool + }{ + { + name: "no skills - no init containers", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-no-skills", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + }, + }, + wantSkillsInit: false, + wantSkillsVolume: false, + }, + { + name: "only OCI skills - unified init container with krane", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-oci-only", 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"}, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsKrane: true, + }, + { + name: "only git skills - unified init container with git clone", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-git-only", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/my-skills", + Ref: "v1.0.0", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsBranch: "v1.0.0", + }, + { + name: "both OCI and git skills - single unified init container", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-both", 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"}, + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/my-skills", + Ref: "main", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsKrane: true, + }, + { + name: "git skill with commit SHA", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-commit", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/my-skills", + Ref: "abc123def456abc123def456abc123def456abc1", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsCommit: "abc123def456abc123def456abc123def456abc1", + }, + { + name: "git skill with path subdirectory", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-path", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/mono-repo", + Ref: "main", + Path: "skills/k8s", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsPath: "skills/k8s", + }, + { + name: "git skills with shared auth secret", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-auth", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitAuthSecretRef: &corev1.LocalObjectReference{ + Name: "github-token", + }, + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/private-skill", + Ref: "main", + }, + { + URL: "https://github.com/org/another-private-skill", + Ref: "v1.0.0", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantAuthVolume: true, + }, + { + name: "git skill with custom name", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-custom-name", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/my-skills.git", + Ref: "main", + Name: "custom-skill", + }, + }, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + }, + { + name: "OCI skills with insecure flag", + agent: &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-insecure", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + InsecureSkipVerify: true, + Refs: []string{"localhost:5000/skill:dev"}, + }, + }, + }, + wantSkillsInit: true, + wantSkillsVolume: true, + wantContainsKrane: 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, "") + + outputs, err := trans.TranslateAgent(context.Background(), tt.agent) + require.NoError(t, err) + require.NotNil(t, outputs) + + // Find deployment in manifest + 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 + + // Find the unified skills-init container + var skillsInitContainer *corev1.Container + for i := range initContainers { + if initContainers[i].Name == "skills-init" { + skillsInitContainer = &initContainers[i] + } + } + + if tt.wantSkillsInit { + require.NotNil(t, skillsInitContainer, "skills-init container should exist") + // There should be exactly one init container + assert.Len(t, initContainers, 1, "should have exactly one init container") + + // Verify the script is passed via /bin/sh -c + require.Len(t, skillsInitContainer.Command, 3) + assert.Equal(t, "/bin/sh", skillsInitContainer.Command[0]) + assert.Equal(t, "-c", skillsInitContainer.Command[1]) + script := skillsInitContainer.Command[2] + + if tt.wantContainsBranch != "" { + assert.Contains(t, script, tt.wantContainsBranch) + assert.Contains(t, script, "--branch") + } + + if tt.wantContainsCommit != "" { + assert.Contains(t, script, tt.wantContainsCommit) + assert.Contains(t, script, "git checkout") + } + + if tt.wantContainsPath != "" { + assert.Contains(t, script, tt.wantContainsPath) + assert.Contains(t, script, "mktemp") + } + + if tt.wantContainsKrane { + assert.Contains(t, script, "krane export") + } + + // Verify /skills volume mount exists + hasSkillsMount := false + for _, vm := range skillsInitContainer.VolumeMounts { + if vm.Name == "kagent-skills" && vm.MountPath == "/skills" { + hasSkillsMount = true + } + } + assert.True(t, hasSkillsMount, "skills-init container should mount kagent-skills volume") + } else { + assert.Nil(t, skillsInitContainer, "skills-init container should not exist") + assert.Empty(t, initContainers, "should have no init containers") + } + + // Check skills volume exists + hasSkillsVolume := false + for _, v := range deployment.Spec.Template.Spec.Volumes { + if v.Name == "kagent-skills" { + hasSkillsVolume = true + if tt.wantSkillsVolume { + assert.NotNil(t, v.EmptyDir, "kagent-skills should be an EmptyDir volume") + } + } + } + if tt.wantSkillsVolume { + assert.True(t, hasSkillsVolume, "kagent-skills volume should exist") + } else { + assert.False(t, hasSkillsVolume, "kagent-skills volume should not exist") + } + + // Check auth volume + if tt.wantAuthVolume { + hasAuthVolume := false + 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.True(t, hasAuthVolume, "git-auth volume should exist") + + // Verify skills-init container has auth volume mount + require.NotNil(t, skillsInitContainer) + hasAuthMount := false + for _, vm := range skillsInitContainer.VolumeMounts { + if vm.Name == "git-auth" && vm.MountPath == "/git-auth" { + hasAuthMount = true + } + } + assert.True(t, hasAuthMount, "skills-init container should mount auth secret") + + // Verify script contains credential helper setup + script := skillsInitContainer.Command[2] + assert.Contains(t, script, "credential.helper") + } + + // Verify insecure flag for OCI skills + if tt.agent.Spec.Skills != nil && tt.agent.Spec.Skills.InsecureSkipVerify { + require.NotNil(t, skillsInitContainer) + script := skillsInitContainer.Command[2] + assert.Contains(t, script, "--insecure") + } + }) + } +} + +func Test_AdkApiTranslator_SkillsConfigurableImage(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, + }, + } + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{Name: "agent-custom-image", Namespace: namespace}, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "test", + ModelConfig: modelName, + }, + Skills: &v1alpha2.SkillForAgent{ + GitRefs: []v1alpha2.GitRepo{ + { + URL: "https://github.com/org/my-skills", + Ref: "main", + }, + }, + }, + }, + } + + // Override the default skills init image config + originalConfig := translator.DefaultSkillsInitImageConfig + translator.DefaultSkillsInitImageConfig = translator.ImageConfig{ + Registry: "custom-registry", + Repository: "skills-init", + Tag: "latest", + } + defer func() { translator.DefaultSkillsInitImageConfig = originalConfig }() + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(modelConfig, agent). + Build() + + defaultModel := types.NamespacedName{ + Namespace: namespace, + Name: modelName, + } + + trans := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") + outputs, err := trans.TranslateAgent(context.Background(), agent) + require.NoError(t, err) + + var deployment *appsv1.Deployment + for _, obj := range outputs.Manifest { + if d, ok := obj.(*appsv1.Deployment); ok { + deployment = d + } + } + require.NotNil(t, deployment) + + var skillsInitContainer *corev1.Container + for i := range deployment.Spec.Template.Spec.InitContainers { + if deployment.Spec.Template.Spec.InitContainers[i].Name == "skills-init" { + skillsInitContainer = &deployment.Spec.Template.Spec.InitContainers[i] + } + } + require.NotNil(t, skillsInitContainer) + assert.Equal(t, "custom-registry/skills-init:latest", skillsInitContainer.Image) +} diff --git a/go/internal/controller/translator/agent/skills-init.sh.tmpl b/go/internal/controller/translator/agent/skills-init.sh.tmpl new file mode 100644 index 000000000..5c4afec0c --- /dev/null +++ b/go/internal/controller/translator/agent/skills-init.sh.tmpl @@ -0,0 +1,62 @@ +set -e +{{- if .AuthMountPath }} +_auth_mount="$(cat <<'ENDVAL' +{{ .AuthMountPath }} +ENDVAL +)" +if [ -f "${_auth_mount}/ssh-privatekey" ]; then + mkdir -p ~/.ssh + cp "${_auth_mount}/ssh-privatekey" ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts +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 +{{- end }} +{{- range .GitRefs }} +_url="$(cat <<'ENDVAL' +{{ .URL }} +ENDVAL +)" +_ref="$(cat <<'ENDVAL' +{{ .Ref }} +ENDVAL +)" +_dest="$(cat <<'ENDVAL' +{{ .Dest }} +ENDVAL +)" +{{- if .IsCommit }} +echo "Cloning ${_url} (commit ${_ref}) into ${_dest}" +git clone -- "$_url" "$_dest" +cd "$_dest" && git checkout "$_ref" +{{- else }} +echo "Cloning ${_url} (ref ${_ref}) into ${_dest}" +git clone --depth 1 --branch "$_ref" -- "$_url" "$_dest" +{{- end }} +{{- if .SubPath }} +_subpath="$(cat <<'ENDVAL' +{{ .SubPath }} +ENDVAL +)" +_tmp="$(mktemp -d)" +cp -a "${_dest}/${_subpath}/." "$_tmp/" +rm -rf "$_dest" +mv "$_tmp" "$_dest" +{{- end }} +{{- end }} +{{- range .OCIRefs }} +_image="$(cat <<'ENDVAL' +{{ .Image }} +ENDVAL +)" +_dest="$(cat <<'ENDVAL' +{{ .Dest }} +ENDVAL +)" +echo "Exporting OCI image ${_image} into ${_dest}" +krane export{{ if $.InsecureOCI }} --insecure{{ end }} "$_image" '/tmp/oci-skill.tar' +mkdir -p "$_dest" +tar xf '/tmp/oci-skill.tar' -C "$_dest" +rm -f '/tmp/oci-skill.tar' +{{- end }} diff --git a/go/internal/controller/translator/agent/skills_unit_test.go b/go/internal/controller/translator/agent/skills_unit_test.go new file mode 100644 index 000000000..b4fefd3fa --- /dev/null +++ b/go/internal/controller/translator/agent/skills_unit_test.go @@ -0,0 +1,212 @@ +package agent + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func Test_ociSkillName(t *testing.T) { + tests := []struct { + name string + imageRef string + want string + }{ + {name: "simple image:tag", imageRef: "skill:latest", want: "skill"}, + {name: "registry/org/skill:tag", imageRef: "ghcr.io/org/skill:v1", want: "skill"}, + {name: "localhost:5000/skill", imageRef: "localhost:5000/skill", want: "skill"}, + {name: "localhost:5000/skill:tag", imageRef: "localhost:5000/skill:v1", want: "skill"}, + {name: "registry:port/org/skill:tag", imageRef: "registry.example.com:8080/org/skill:v1", want: "skill"}, + {name: "digest ref", imageRef: "ghcr.io/org/skill@sha256:abc123", want: "skill"}, + {name: "tag and digest", imageRef: "ghcr.io/org/skill:v1@sha256:abc123", want: "skill"}, + {name: "deeply nested", imageRef: "registry.io/a/b/c/skill:latest", want: "skill"}, + {name: "no tag no digest", imageRef: "ghcr.io/org/skill", want: "skill"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ociSkillName(tt.imageRef) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_gitSkillName(t *testing.T) { + tests := []struct { + name string + ref v1alpha2.GitRepo + want string + }{ + { + name: "explicit name takes precedence", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/repo.git", Name: "custom"}, + want: "custom", + }, + { + name: "strips .git suffix", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/my-repo.git"}, + want: "my-repo", + }, + { + name: "no .git suffix", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/my-repo"}, + want: "my-repo", + }, + { + name: "strips query params", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/repo.git?token=abc"}, + want: "repo", + }, + { + name: "strips fragment", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/repo.git#readme"}, + want: "repo", + }, + { + name: "strips query and fragment", + ref: v1alpha2.GitRepo{URL: "https://github.com/org/repo?foo=bar#baz"}, + want: "repo", + }, + { + name: "SSH URL", + ref: v1alpha2.GitRepo{URL: "git@github.com:org/repo.git"}, + want: "repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := gitSkillName(tt.ref) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_validateSubPath(t *testing.T) { + tests := []struct { + name string + path string + wantErr string + }{ + {name: "empty is valid", path: "", wantErr: ""}, + {name: "simple relative path", path: "skills/k8s", wantErr: ""}, + {name: "single segment", path: "subdir", wantErr: ""}, + {name: "absolute path rejected", path: "/etc/passwd", wantErr: "must be relative"}, + {name: "dotdot at start rejected", path: "../escape", wantErr: "must not contain '..'"}, + {name: "dotdot in middle rejected", path: "a/../b", wantErr: "must not contain '..'"}, + {name: "dotdot at end rejected", path: "a/b/..", wantErr: "must not contain '..'"}, + {name: "bare dotdot rejected", path: "..", wantErr: "must not contain '..'"}, + {name: "dots in name are ok", path: "my.skill/v1.0", wantErr: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSubPath(tt.path) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_prepareSkillsInitData_duplicateNames(t *testing.T) { + tests := []struct { + name string + gitRefs []v1alpha2.GitRepo + ociRefs []string + wantErr string + }{ + { + name: "no duplicates", + gitRefs: []v1alpha2.GitRepo{ + {URL: "https://github.com/org/skill-a", Ref: "main"}, + {URL: "https://github.com/org/skill-b", Ref: "main"}, + }, + wantErr: "", + }, + { + name: "duplicate git repos", + gitRefs: []v1alpha2.GitRepo{ + {URL: "https://github.com/org/skill-a", Ref: "main"}, + {URL: "https://github.com/other/skill-a", Ref: "main"}, + }, + wantErr: `duplicate skill directory name "skill-a"`, + }, + { + name: "duplicate OCI refs", + ociRefs: []string{ + "ghcr.io/org/skill:v1", + "ghcr.io/other/skill:v2", + }, + wantErr: `duplicate skill directory name "skill"`, + }, + { + name: "git and OCI collision", + gitRefs: []v1alpha2.GitRepo{ + {URL: "https://github.com/org/my-skill", Ref: "main"}, + }, + ociRefs: []string{ + "ghcr.io/org/my-skill:v1", + }, + wantErr: `duplicate skill directory name "my-skill"`, + }, + { + name: "explicit name avoids collision", + gitRefs: []v1alpha2.GitRepo{ + {URL: "https://github.com/org/skill-a", Ref: "main", Name: "unique-a"}, + {URL: "https://github.com/org/skill-a", Ref: "v2", Name: "unique-b"}, + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := prepareSkillsInitData(tt.gitRefs, nil, tt.ociRefs, false) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_prepareSkillsInitData_pathTraversal(t *testing.T) { + _, err := prepareSkillsInitData( + []v1alpha2.GitRepo{ + {URL: "https://github.com/org/repo", Ref: "main", Path: "../escape"}, + }, + nil, nil, false, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain '..'") +} + +func Test_prepareSkillsInitData_absolutePath(t *testing.T) { + _, err := prepareSkillsInitData( + []v1alpha2.GitRepo{ + {URL: "https://github.com/org/repo", Ref: "main", Path: "/etc/passwd"}, + }, + nil, nil, false, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be relative") +} + +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, + ) + require.NoError(t, err) + assert.Equal(t, "/git-auth", data.AuthMountPath) +} diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_git_skills.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_git_skills.yaml new file mode 100644 index 000000000..8a9683ed3 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_git_skills.yaml @@ -0,0 +1,54 @@ +operation: translateAgent +targetObject: git-skills-agent +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: basic-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + openAI: + temperature: "0.7" + maxTokens: 1024 + topP: "0.95" + reasoningEffort: "low" + defaultHeaders: + User-Agent: "kagent/1.0" + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: git-skills-agent + namespace: test + spec: + skills: + refs: + - ghcr.io/org/oci-skill:v1.0 + gitAuthSecretRef: + name: github-token + gitRefs: + - url: https://github.com/org/my-skills + ref: v2.0.0 + path: skills/k8s + name: k8s-skill + - url: https://github.com/org/another-skill + ref: abc123def456abc123def456abc123def456abc1 + - url: https://github.com/org/private-skill + ref: main + type: Declarative + declarative: + description: An agent with git-based skills + systemMessage: You are a helpful assistant with skills from git. + modelConfig: basic-model + tools: [] diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json new file mode 100644 index 000000000..dffefcdd6 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_git_skills.json @@ -0,0 +1,340 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "git_skills_agent", + "skills": null, + "url": "http://git-skills-agent.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": null, + "instruction": "You are a helpful assistant with skills from git.", + "model": { + "base_url": "", + "headers": { + "User-Agent": "kagent/1.0" + }, + "max_tokens": 1024, + "model": "gpt-4o", + "reasoning_effort": "low", + "temperature": 0.7, + "top_p": 0.95, + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null, + "stream": false + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "git-skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "git-skills-agent" + }, + "name": "git-skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "git-skills-agent", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"git_skills_agent\",\"description\":\"\",\"url\":\"http://git-skills-agent.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"headers\":{\"User-Agent\":\"kagent/1.0\"},\"base_url\":\"\",\"max_tokens\":1024,\"reasoning_effort\":\"low\",\"temperature\":0.7,\"top_p\":0.95},\"description\":\"\",\"instruction\":\"You are a helpful assistant with skills from git.\",\"http_tools\":null,\"sse_tools\":null,\"remote_agents\":null,\"stream\":false}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "git-skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "git-skills-agent" + }, + "name": "git-skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "git-skills-agent", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "git-skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "git-skills-agent" + }, + "name": "git-skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "git-skills-agent", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "git-skills-agent" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "11835878454410491036" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "git-skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "git-skills-agent" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "git-skills-agent" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + }, + { + "name": "KAGENT_SKILLS_FOLDER", + "value": "/skills" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "securityContext": { + "privileged": true + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/skills", + "name": "kagent-skills", + "readOnly": true + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "initContainers": [ + { + "command": [ + "/bin/sh", + "-c", + "set -e\n_auth_mount=\"$(cat \u003c\u003c'ENDVAL'\n/git-auth\nENDVAL\n)\"\nif [ -f \"${_auth_mount}/ssh-privatekey\" ]; then\n mkdir -p ~/.ssh\n cp \"${_auth_mount}/ssh-privatekey\" ~/.ssh/id_rsa\n chmod 600 ~/.ssh/id_rsa\n ssh-keyscan github.com gitlab.com bitbucket.org \u003e\u003e ~/.ssh/known_hosts\nelif [ -f \"${_auth_mount}/token\" ]; then\n git config --global credential.helper \"!f() { echo username=x-access-token; echo password=\\$(cat ${_auth_mount}/token); }; f\"\nfi\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/my-skills\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nv2.0.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/k8s-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_subpath=\"$(cat \u003c\u003c'ENDVAL'\nskills/k8s\nENDVAL\n)\"\n_tmp=\"$(mktemp -d)\"\ncp -a \"${_dest}/${_subpath}/.\" \"$_tmp/\"\nrm -rf \"$_dest\"\nmv \"$_tmp\" \"$_dest\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/another-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nabc123def456abc123def456abc123def456abc1\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/another-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (commit ${_ref}) into ${_dest}\"\ngit clone -- \"$_url\" \"$_dest\"\ncd \"$_dest\" \u0026\u0026 git checkout \"$_ref\"\n_url=\"$(cat \u003c\u003c'ENDVAL'\nhttps://github.com/org/private-skill\nENDVAL\n)\"\n_ref=\"$(cat \u003c\u003c'ENDVAL'\nmain\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/private-skill\nENDVAL\n)\"\necho \"Cloning ${_url} (ref ${_ref}) into ${_dest}\"\ngit clone --depth 1 --branch \"$_ref\" -- \"$_url\" \"$_dest\"\n_image=\"$(cat \u003c\u003c'ENDVAL'\nghcr.io/org/oci-skill:v1.0\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/oci-skill\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\nkrane export \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" + ], + "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", + "name": "skills-init", + "resources": {}, + "volumeMounts": [ + { + "mountPath": "/skills", + "name": "kagent-skills" + }, + { + "mountPath": "/git-auth", + "name": "git-auth", + "readOnly": true + } + ] + } + ], + "serviceAccountName": "git-skills-agent", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "git-skills-agent" + } + }, + { + "emptyDir": {}, + "name": "kagent-skills" + }, + { + "name": "git-auth", + "secret": { + "secretName": "github-token" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "git-skills-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "git-skills-agent" + }, + "name": "git-skills-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "git-skills-agent", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "git-skills-agent" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json index 2114d65cd..54583a8bf 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_skills.json @@ -235,20 +235,12 @@ ], "initContainers": [ { - "args": [ - "foo:latest" - ], "command": [ - "kagent-adk", - "pull-skills" + "/bin/sh", + "-c", + "set -e\n_image=\"$(cat \u003c\u003c'ENDVAL'\nfoo:latest\nENDVAL\n)\"\n_dest=\"$(cat \u003c\u003c'ENDVAL'\n/skills/foo\nENDVAL\n)\"\necho \"Exporting OCI image ${_image} into ${_dest}\"\nkrane export \"$_image\" '/tmp/oci-skill.tar'\nmkdir -p \"$_dest\"\ntar xf '/tmp/oci-skill.tar' -C \"$_dest\"\nrm -f '/tmp/oci-skill.tar'\n" ], - "env": [ - { - "name": "KAGENT_SKILLS_FOLDER", - "value": "/skills" - } - ], - "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "image": "cr.kagent.dev/kagent-dev/kagent/skills-init:dev", "name": "skills-init", "resources": {}, "volumeMounts": [ diff --git a/go/pkg/app/app.go b/go/pkg/app/app.go index fe7f06b13..648620856 100644 --- a/go/pkg/app/app.go +++ b/go/pkg/app/app.go @@ -172,6 +172,10 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.StringVar(&agent_translator.DefaultImageConfig.PullPolicy, "image-pull-policy", agent_translator.DefaultImageConfig.PullPolicy, "The pull policy to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.PullSecret, "image-pull-secret", "", "The pull secret name for the agent image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Repository, "image-repository", agent_translator.DefaultImageConfig.Repository, "The repository to use for the agent image.") + commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Registry, "skills-init-image-registry", agent_translator.DefaultSkillsInitImageConfig.Registry, "The registry to use for the skills init image.") + commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Tag, "skills-init-image-tag", agent_translator.DefaultSkillsInitImageConfig.Tag, "The tag to use for the skills init image.") + commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "skills-init-image-pull-policy", agent_translator.DefaultSkillsInitImageConfig.PullPolicy, "The pull policy to use for the skills init image.") + commandLine.StringVar(&agent_translator.DefaultSkillsInitImageConfig.Repository, "skills-init-image-repository", agent_translator.DefaultSkillsInitImageConfig.Repository, "The repository to use for the skills init image.") } // LoadFromEnv loads configuration values from environment variables. diff --git a/helm/agents/k8s/templates/agent.yaml b/helm/agents/k8s/templates/agent.yaml index 898839cc3..bc338dbba 100644 --- a/helm/agents/k8s/templates/agent.yaml +++ b/helm/agents/k8s/templates/agent.yaml @@ -182,4 +182,4 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} resources: - {{- toYaml .Values.resources | nindent 8 }} \ No newline at end of file + {{- toYaml .Values.resources | nindent 8 }} diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index 418225433..f2e060572 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -10027,6 +10027,52 @@ spec: Skills to load into the agent. They will be pulled from the specified container images. and made available to the agent under the `/skills` folder. properties: + gitAuthSecretRef: + description: |- + Reference to a Secret containing git credentials. + Applied to all gitRefs entries. + The secret should contain a `token` key for HTTPS auth, + or `ssh-privatekey` for SSH auth. + 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 + gitRefs: + description: Git repositories to fetch skills from. + items: + description: GitRepo specifies a single Git repository to fetch + skills from. + properties: + name: + description: Name for the skill directory under /skills. + Defaults to the repo name. + type: string + path: + description: Subdirectory within the repo to use as the + skill root. + type: string + ref: + default: main + description: 'Git reference: branch name, tag, or commit + SHA.' + type: string + url: + description: URL of the git repository (HTTPS or SSH). + type: string + required: + - url + type: object + maxItems: 20 + minItems: 1 + type: array insecureSkipVerify: description: |- Fetch images insecurely from registries (allowing HTTP and skipping TLS verification). diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index f0a289119..9f5780cd8 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -17,6 +17,10 @@ data: IMAGE_REGISTRY: {{ .Values.controller.agentImage.registry | default .Values.registry | quote }} IMAGE_REPOSITORY: {{ .Values.controller.agentImage.repository | quote }} IMAGE_TAG: {{ coalesce .Values.controller.agentImage.tag .Values.tag .Chart.Version | quote }} + SKILLS_INIT_IMAGE_PULL_POLICY: {{ .Values.controller.skillsInitImage.pullPolicy | default .Values.imagePullPolicy | quote }} + SKILLS_INIT_IMAGE_REGISTRY: {{ .Values.controller.skillsInitImage.registry | default .Values.registry | quote }} + SKILLS_INIT_IMAGE_REPOSITORY: {{ .Values.controller.skillsInitImage.repository | quote }} + SKILLS_INIT_IMAGE_TAG: {{ coalesce .Values.controller.skillsInitImage.tag .Values.tag .Chart.Version | quote }} LEADER_ELECT: {{ include "kagent.leaderElectionEnabled" . | quote }} # OpenTelemetry Configuration OTEL_TRACING_ENABLED: {{ .Values.otel.tracing.enabled | quote }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index fb84495f9..ed47ddf28 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -75,6 +75,12 @@ controller: repository: kagent-dev/kagent/app tag: "" # Will default to global, then Chart version pullPolicy: "" + # -- The image used by the skills-init container to clone skills from Git and pull OCI skill images. + skillsInitImage: + registry: "" + repository: kagent-dev/kagent/skills-init + tag: "" # Will default to global, then Chart version + pullPolicy: "" streaming: # Streaming buffer size for A2A communication maxBufSize: 1Mi # 1024 * 1024 initialBufSize: 4Ki # 4 * 1024 diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index ff1b1b21a..2a44a196a 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -15,7 +15,6 @@ from kagent.core import KAgentConfig, configure_logging, configure_tracing from . import AgentConfig, KAgentApp -from .skill_fetcher import fetch_skill from .tools import add_skills_tool_to_agent logger = logging.getLogger(__name__) @@ -102,20 +101,6 @@ def root_agent_factory() -> BaseAgent: ) -@app.command() -def pull_skills( - skills: Annotated[list[str], typer.Argument()], - insecure: Annotated[ - bool, - typer.Option("--insecure", help="Allow insecure connections to registries"), - ] = False, -): - skill_dir = os.environ.get("KAGENT_SKILLS_FOLDER", ".") - logger.info("Pulling skills") - for skill in skills: - fetch_skill(skill, skill_dir, insecure) - - def add_to_agent(sts_integration: ADKTokenPropagationPlugin, agent: BaseAgent): """ Add the plugin to an ADK LLM agent by updating its MCP toolset diff --git a/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py b/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py deleted file mode 100644 index 382882b17..000000000 --- a/python/packages/kagent-adk/src/kagent/adk/skill_fetcher.py +++ /dev/null @@ -1,103 +0,0 @@ -from __future__ import annotations - -import logging -import os -import tarfile -from typing import Tuple - -logger = logging.getLogger(__name__) - - -def _parse_image_ref(image: str) -> Tuple[str, str, str]: - """ - Parse an OCI/Docker image reference into (registry, repository, reference). - - reference is either a tag (default "latest") or a digest (e.g., "sha256:..."). - Rules (compatible with Docker/OCI name parsing): - - If the reference contains a digest ("@"), prefer a tag if also present (repo:tag@digest), - otherwise keep the digest as the reference. - - If there is no tag nor digest, default the reference to "latest". - - If the first path component contains a '.' or ':' or equals 'localhost', it is treated as the registry. - Otherwise the registry defaults to docker hub (docker.io), with the special library namespace for single-component names. - """ - name_part = image - ref = "latest" - - if "@" in image: - # Split digest - name_part, digest = image.split("@", 1) - ref = digest - - # Possibly has a tag: detect a colon after the last slash - slash = name_part.rfind("/") - colon = name_part.rfind(":") - if colon > slash: - ref = name_part[colon + 1 :] - name_part = name_part[:colon] - # else: keep default "latest" - - # Determine registry and repo path - parts = name_part.split("/") - if len(parts) == 1: - # Implicit docker hub library image - registry = "registry-1.docker.io" - repo = f"library/{parts[0]}" - else: - first = parts[0] - if first == "localhost" or "." in first or ":" in first: - # Explicit registry (may include port) - registry = first - repo = "/".join(parts[1:]) - else: - # Docker hub with user/org namespace - registry = "docker.io" - repo = "/".join(parts) - - return registry, repo, ref - - -def fetch_using_krane_to_dir(image: str, destination_folder: str, insecure: bool = False) -> None: - """Fetch a skill using krane and extract it to destination_folder.""" - import subprocess - - tar_path = os.path.join(destination_folder, "skill.tar") - os.makedirs(destination_folder, exist_ok=True) - command = ["krane", "export", image, tar_path] - if insecure: - command.insert(1, "--insecure") - # Use krane to pull the image as a tarball - subprocess.run( - command, - check=True, - ) - - # Extract the tarball - with tarfile.open(tar_path, "r") as tar: - tar.extractall(path=destination_folder, filter=tarfile.data_filter) - - # Remove the tarball - os.remove(tar_path) - - -def fetch_skill(skill_image: str, destination_folder: str, insecure: bool = False) -> None: - """ - Fetch a skill packaged as an OCI/Docker image and write its files to destination_folder. - - To build a compatible skill image from a folder (containing SKILL.md), use a simple Dockerfile: - FROM scratch - COPY . / - - Args: - skill_image: The image reference (e.g., "alpine:latest", "ghcr.io/org/skill:tag", or with a digest). - destination_folder: The folder where the skill files should be written. - """ - registry, repo, ref = _parse_image_ref(skill_image) - - # skill name is the last part of the repo - repo_parts = repo.split("/") - skill_name = repo_parts[-1] - logger.info( - f"about to fetching skill {skill_name} from image {skill_image} (registry: {registry}, repo: {repo}, ref: {ref})" - ) - - fetch_using_krane_to_dir(skill_image, os.path.join(destination_folder, skill_name), insecure) diff --git a/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py b/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py deleted file mode 100644 index 70c04781e..000000000 --- a/python/packages/kagent-adk/tests/unittests/test_skill_fetcher_parse.py +++ /dev/null @@ -1,56 +0,0 @@ -import os -import sys -from pathlib import Path - -import pytest - -# Ensure the package's src/ is on sys.path for "src" layout -_PKG_ROOT = Path(__file__).resolve().parents[2] # .../packages/kagent-adk -_SRC = _PKG_ROOT / "src" -if str(_SRC) not in sys.path: - sys.path.insert(0, str(_SRC)) - -from kagent.adk.skill_fetcher import _parse_image_ref # noqa: E402 - - -@pytest.mark.parametrize( - "image,expected", - [ - # Docker Hub implicit library and latest tag - ("alpine", ("registry-1.docker.io", "library/alpine", "latest")), - ("ubuntu", ("registry-1.docker.io", "library/ubuntu", "latest")), - # Explicit tag on Docker Hub implicit library - ("alpine:3.19", ("registry-1.docker.io", "library/alpine", "3.19")), - # User namespace on Docker Hub - ("user/image", ("docker.io", "user/image", "latest")), - ("user/image:tag", ("docker.io", "user/image", "tag")), - # Fully-qualified registry without tag -> default latest - ("ghcr.io/org/skill", ("ghcr.io", "org/skill", "latest")), - ("ghcr.io/org/skill:1.2.3", ("ghcr.io", "org/skill", "1.2.3")), - # Digest reference - ( - "ghcr.io/org/skill@sha256:abcdef", - ("ghcr.io", "org/skill", "sha256:abcdef"), - ), - # Tag + digest present: keep the tag as ref (current behavior) - ( - "ghcr.io/org/skill:1@sha256:abcdef", - ("ghcr.io", "org/skill", "1"), - ), - # Registry with port - ( - "registry.example.com:5000/repo/image:tag", - ("registry.example.com:5000", "repo/image", "tag"), - ), - ( - "registry.example.com:5000/repo/image", - ("registry.example.com:5000", "repo/image", "latest"), - ), - ( - "localhost:5000/image", - ("localhost:5000", "image", "latest"), - ), - ], -) -def test_parse_image_ref(image, expected): - assert _parse_image_ref(image) == expected