diff --git a/api/v1alpha1/agentteam_types.go b/api/v1alpha1/agentteam_types.go index 046e701..8238035 100644 --- a/api/v1alpha1/agentteam_types.go +++ b/api/v1alpha1/agentteam_types.go @@ -66,6 +66,16 @@ type AgentTeamSpec struct { // +kubebuilder:default="claude-code" // +optional Harness string `json:"harness,omitempty"` + + // ImagePullSecrets are credentials for pulling private container + // images, including OCI-distributed skills. The same secrets are + // applied to agent pods (for the runner image) and to skill-puller + // init containers (for pulling skill artifacts via ORAS). Use + // kubernetes.io/dockerconfigjson Secrets — the operator mounts them + // into the init container so ORAS can resolve registry credentials + // from $DOCKER_CONFIG. + // +optional + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` } // RepositorySpec defines the git repository configuration. @@ -127,20 +137,37 @@ type LeadSpec struct { Resources corev1.ResourceRequirements `json:"resources,omitempty"` } -// SkillSource identifies where to load a skill from. Exactly one field should be set. +// SkillSource identifies where to load a skill from. Exactly one of +// ConfigMap or OCI must be set (enforced by CEL on SkillSpec). +// +// ConfigMap is simplest and lives entirely within the cluster — good +// for skills authored alongside the team CRs. OCI distributes skills +// as registry artifacts so they can be versioned, signed, shared +// across clusters, and pulled from public or private registries. type SkillSource struct { // ConfigMap references a ConfigMap in the same namespace. // Each key in the ConfigMap becomes a file in the skill directory. // +optional ConfigMap string `json:"configMap,omitempty"` - // OCI is an OCI artifact reference containing the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). - // TODO: OCI skill pulling is not yet implemented; use ConfigMap instead. + // OCI is an OCI artifact reference containing the skill files + // (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + // an `oras pull` init container at pod startup to materialize the + // skill onto an emptyDir; the main container then sees the files + // under ~/.claude/skills/{name}/. Private registries are supported + // via spec.imagePullSecrets. + // + // Re-pull semantics: the init container runs once per pod start, + // so the artifact is re-pulled on every pod create. There is no + // shared cache between pods — operators who want one should pin + // to immutable digests so the registry can short-circuit identical + // pulls cheaply. // +optional OCI string `json:"oci,omitempty"` } // SkillSpec defines a Claude Code skill to mount into an agent pod. +// +kubebuilder:validation:XValidation:rule="(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) ? 1 : 0) == 1",message="skill source must set exactly one of configMap or oci" type SkillSpec struct { // Name is the skill directory name under .claude/skills/. Name string `json:"name"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a94b092..07f02fa 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,8 @@ limitations under the License. package v1alpha1 import ( - "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -360,6 +361,11 @@ func (in *AgentTeamSpec) DeepCopyInto(out *AgentTeamSpec) { *out = new(PipelineSpec) (*in).DeepCopyInto(*out) } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]v1.LocalObjectReference, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AgentTeamSpec. @@ -424,7 +430,7 @@ func (in *AgentTeamStatus) DeepCopyInto(out *AgentTeamStatus) { } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) + *out = make([]metav1.Condition, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -542,7 +548,7 @@ func (in *AgentTeamTemplateStatus) DeepCopyInto(out *AgentTeamTemplateStatus) { *out = *in if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions - *out = make([]v1.Condition, len(*in)) + *out = make([]metav1.Condition, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/charts/kagents/crds/kagents.dev_agentteamruns.yaml b/charts/kagents/crds/kagents.dev_agentteamruns.yaml index e48869e..2cb0014 100644 --- a/charts/kagents/crds/kagents.dev_agentteamruns.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamruns.yaml @@ -187,14 +187,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/charts/kagents/crds/kagents.dev_agentteams.yaml b/charts/kagents/crds/kagents.dev_agentteams.yaml index 5d3f1bd..fd17ac4 100644 --- a/charts/kagents/crds/kagents.dev_agentteams.yaml +++ b/charts/kagents/crds/kagents.dev_agentteams.yaml @@ -119,6 +119,32 @@ spec: enum: - claude-code type: string + imagePullSecrets: + description: |- + ImagePullSecrets are credentials for pulling private container + images, including OCI-distributed skills. The same secrets are + applied to agent pods (for the runner image) and to skill-puller + init containers (for pulling skill artifacts via ORAS). Use + kubernetes.io/dockerconfigjson Secrets — the operator mounts them + into the init container so ORAS can resolve registry credentials + from $DOCKER_CONFIG. + 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 + type: array lead: description: Lead configures the team lead agent. properties: @@ -244,14 +270,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt @@ -825,14 +867,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap + or oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - name diff --git a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml index 2ca45e8..68a717b 100644 --- a/charts/kagents/crds/kagents.dev_agentteamschedules.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamschedules.yaml @@ -206,14 +206,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml index 49435c8..4171747 100644 --- a/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamtemplates.yaml @@ -522,14 +522,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap + or oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - name diff --git a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml index 4cc54d1..8d30397 100644 --- a/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml +++ b/charts/kagents/crds/kagents.dev_agentteamtriggers.yaml @@ -217,14 +217,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/cmd/manager/main.go b/cmd/manager/main.go index fecaebf..f877d4c 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -37,6 +37,7 @@ func main() { var enableLeaderElection bool var agentImage string var initImage string + var skillPullerImage string var skipInitScript bool var pvcAccessMode string var agentCommand string @@ -46,6 +47,7 @@ func main() { flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") flag.StringVar(&agentImage, "agent-image", "", "Override the container image used for agent pods (default: ghcr.io/amcheste/claude-code-runner:latest).") flag.StringVar(&initImage, "init-image", "", "Override the container image used for the repo init Job (default: alpine/git:latest).") + flag.StringVar(&skillPullerImage, "skill-puller-image", "", "Override the container image used to pull OCI-distributed skills (default: ghcr.io/oras-project/oras:v1.2.0). Air-gapped clusters can pin to an internal mirror.") flag.BoolVar(&skipInitScript, "skip-init-script", false, "Replace the init Job git-clone script with a no-op exit 0. Use in acceptance tests where no real repo is available.") flag.StringVar(&pvcAccessMode, "pvc-access-mode", "", "Override PVC access mode for all operator-managed PVCs (ReadWriteMany|ReadWriteOnce). Defaults to ReadWriteMany. Set to ReadWriteOnce for single-node clusters like Kind.") flag.StringVar(&agentCommand, "agent-command", "", "Override the agent container command as a comma-separated list (e.g. sh,-c,sleep 30 && exit 0). Used in acceptance tests to keep pods alive long enough to observe.") @@ -71,13 +73,14 @@ func main() { } reconciler := &controller.AgentTeamReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - AgentImage: agentImage, - InitImage: initImage, - SkipInitScript: skipInitScript, - Harnesses: harness.DefaultRegistry(), - Delivery: delivery.NewDispatcher(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + AgentImage: agentImage, + InitImage: initImage, + SkillPullerImage: skillPullerImage, + SkipInitScript: skipInitScript, + Harnesses: harness.DefaultRegistry(), + Delivery: delivery.NewDispatcher(), } if agentCommand != "" { reconciler.AgentCommand = strings.Split(agentCommand, ",") diff --git a/config/crd/bases/kagents.dev_agentteamruns.yaml b/config/crd/bases/kagents.dev_agentteamruns.yaml index e48869e..2cb0014 100644 --- a/config/crd/bases/kagents.dev_agentteamruns.yaml +++ b/config/crd/bases/kagents.dev_agentteamruns.yaml @@ -187,14 +187,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/config/crd/bases/kagents.dev_agentteams.yaml b/config/crd/bases/kagents.dev_agentteams.yaml index 5d3f1bd..fd17ac4 100644 --- a/config/crd/bases/kagents.dev_agentteams.yaml +++ b/config/crd/bases/kagents.dev_agentteams.yaml @@ -119,6 +119,32 @@ spec: enum: - claude-code type: string + imagePullSecrets: + description: |- + ImagePullSecrets are credentials for pulling private container + images, including OCI-distributed skills. The same secrets are + applied to agent pods (for the runner image) and to skill-puller + init containers (for pulling skill artifacts via ORAS). Use + kubernetes.io/dockerconfigjson Secrets — the operator mounts them + into the init container so ORAS can resolve registry credentials + from $DOCKER_CONFIG. + 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 + type: array lead: description: Lead configures the team lead agent. properties: @@ -244,14 +270,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt @@ -825,14 +867,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap + or oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - name diff --git a/config/crd/bases/kagents.dev_agentteamschedules.yaml b/config/crd/bases/kagents.dev_agentteamschedules.yaml index 2ca45e8..68a717b 100644 --- a/config/crd/bases/kagents.dev_agentteamschedules.yaml +++ b/config/crd/bases/kagents.dev_agentteamschedules.yaml @@ -206,14 +206,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/config/crd/bases/kagents.dev_agentteamtemplates.yaml b/config/crd/bases/kagents.dev_agentteamtemplates.yaml index 49435c8..4171747 100644 --- a/config/crd/bases/kagents.dev_agentteamtemplates.yaml +++ b/config/crd/bases/kagents.dev_agentteamtemplates.yaml @@ -522,14 +522,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap + or oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - name diff --git a/config/crd/bases/kagents.dev_agentteamtriggers.yaml b/config/crd/bases/kagents.dev_agentteamtriggers.yaml index 4cc54d1..8d30397 100644 --- a/config/crd/bases/kagents.dev_agentteamtriggers.yaml +++ b/config/crd/bases/kagents.dev_agentteamtriggers.yaml @@ -217,14 +217,30 @@ spec: Each key in the ConfigMap becomes a file in the skill directory. type: string oci: - description: OCI is an OCI artifact reference containing - the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). + description: |- + OCI is an OCI artifact reference containing the skill files + (e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs + an `oras pull` init container at pod startup to materialize the + skill onto an emptyDir; the main container then sees the files + under ~/.claude/skills/{name}/. Private registries are supported + via spec.imagePullSecrets. + + Re-pull semantics: the init container runs once per pod start, + so the artifact is re-pulled on every pod create. There is no + shared cache between pods — operators who want one should pin + to immutable digests so the registry can short-circuit identical + pulls cheaply. type: string type: object required: - name - source type: object + x-kubernetes-validations: + - message: skill source must set exactly one of configMap or + oci + rule: '(has(self.source.configMap) ? 1 : 0) + (has(self.source.oci) + ? 1 : 0) == 1' type: array required: - prompt diff --git a/config/samples/oci-skills-team.yaml b/config/samples/oci-skills-team.yaml new file mode 100644 index 0000000..f4f5110 --- /dev/null +++ b/config/samples/oci-skills-team.yaml @@ -0,0 +1,71 @@ +# OCI skill distribution sample — a Cowork team whose teammates load +# skills from an OCI registry instead of ConfigMaps. +# +# Behavior: +# - For each skill with `source.oci`, the operator adds an init +# container that runs `oras pull` into a per-skill emptyDir. +# - The main container mounts the same emptyDir read-only at +# /var/claude-skills/{name}/; the runner entrypoint copies it to +# ~/.claude/skills/{name}/ before launching Claude Code. +# - ConfigMap-backed skills still work — both sources can coexist +# on the same teammate. +# - Private registries: list a kubernetes.io/dockerconfigjson Secret +# under spec.imagePullSecrets. The operator both (a) propagates it +# to the pod for kubelet image pulls, and (b) mounts its +# .dockerconfigjson into the puller init container so ORAS sees +# credentials via $DOCKER_CONFIG. +# +# Setup before applying (only if any skill is in a private registry): +# kubectl -n cowork-agents create secret docker-registry ghcr-creds \ +# --docker-server=ghcr.io \ +# --docker-username=$GHCR_USER \ +# --docker-password=$GHCR_PAT +# +# Apply with: +# kubectl apply -n cowork-agents -f config/samples/oci-skills-team.yaml +apiVersion: kagents.dev/v1alpha1 +kind: AgentTeam +metadata: + name: market-research + namespace: cowork-agents +spec: + workspace: + output: + mountPath: "/workspace/output" + size: "1Gi" + + auth: + apiKeySecret: "anthropic-api-key" + + # Optional — only needed for private skill registries. The operator + # propagates this to the agent pod and to skill-puller init + # containers so both layers see credentials. + imagePullSecrets: + - name: ghcr-creds + + lead: + model: "opus" + prompt: | + Coordinate a market-research brief. Use the web-research skill + to gather sources and the report-writing skill to format the + output. + + teammates: + - name: "researcher" + model: "sonnet" + prompt: | + Research the topic and write findings to + /workspace/output/findings.md. Use the skills you've been + given for instructions. + skills: + # Public skill — pulled from a public registry, no creds needed. + - name: web-research + source: + oci: "ghcr.io/kagents/skills/web-research:v1" + # Private skill — works because spec.imagePullSecrets is set. + - name: report-writing + source: + oci: "ghcr.io/acme-internal/skills/report-writing:v3" + outputs: + - path: "/workspace/output/findings.md" + description: "Research findings." diff --git a/docs/how-to/skill-authoring.md b/docs/how-to/skill-authoring.md new file mode 100644 index 0000000..59c1879 --- /dev/null +++ b/docs/how-to/skill-authoring.md @@ -0,0 +1,150 @@ +# Authoring and Publishing Skills + +Skills are the unit of expertise a Claude Code agent loads at startup. +They're a directory mounted at `~/.claude/skills/{name}/` containing +instructions, examples, and templates. kagents supports two +distribution mechanisms: + +- **ConfigMap** — skill files live inside the cluster as a ConfigMap. + Simplest, no network. Good for skills authored alongside the team CR. +- **OCI** — skill files are packaged as an OCI artifact in a registry + (ghcr.io, ECR, GCR, internal Harbor, etc.) and pulled by an init + container at pod startup. Versioned, signed, shared across clusters. + +This page covers the OCI path. ConfigMap skills are just key/value +files — see the API reference for `SkillSource.configMap`. + +## Anatomy of a skill + +A skill is a directory. Anthropic's Claude Code reads `SKILL.md` at +load time and treats anything else in the directory as supplementary +material it can reference. + +``` +my-skill/ +├── SKILL.md # required: the skill's instructions +├── examples/ # optional: example inputs and expected outputs +│ ├── input-1.md +│ └── output-1.md +└── templates/ # optional: output formats the agent fills in + └── report.tmpl +``` + +`SKILL.md` is markdown. It typically describes what the skill does, +when the agent should reach for it, and any conventions the agent +should follow. + +## Publishing as an OCI artifact + +[ORAS](https://oras.land) is the CLI that pushes arbitrary directories +to an OCI-compatible registry. The kagents operator uses the same tool +to pull them back at pod startup. + +```bash +# From the skill's directory: +oras push ghcr.io/myorg/skills/my-skill:v1 \ + --artifact-type application/vnd.kagents.skill.v1 \ + . +``` + +A few notes on the command above: + +- `--artifact-type` is conventional. The operator does not enforce it + today; future versions may use it to validate that an OCI reference + points at a skill rather than a random container image. +- Tag immutably (`:v1`, `:v1.0.3`, or `@sha256:...`) — `:latest` + works but means every pod re-pulls in case the content moved. +- Pin to a digest (`@sha256:...`) in production for byte-for-byte + reproducibility and to let the registry short-circuit identical + pulls cheaply. + +## Referencing a skill from an AgentTeam + +```yaml +teammates: + - name: researcher + skills: + - name: my-skill + source: + oci: ghcr.io/myorg/skills/my-skill:v1 +``` + +The operator pulls `ghcr.io/myorg/skills/my-skill:v1` into a per-skill +emptyDir, mounts it at `/var/claude-skills/my-skill/`, and the runner +entrypoint copies it to `~/.claude/skills/my-skill/` before launching +Claude Code. + +## Private registries + +For skills in a private registry, point the team at a +`kubernetes.io/dockerconfigjson` Secret: + +```bash +kubectl create secret docker-registry ghcr-creds \ + --docker-server=ghcr.io \ + --docker-username=$GHCR_USER \ + --docker-password=$GHCR_PAT +``` + +```yaml +spec: + imagePullSecrets: + - name: ghcr-creds + teammates: + - name: researcher + skills: + - name: internal-skill + source: + oci: ghcr.io/internal/skills/private:v1 +``` + +The operator does two things with the secret: + +1. **Propagates it to the pod** so the kubelet can pull the runner + image and the ORAS puller image from a private registry. +2. **Mounts its `.dockerconfigjson` into the puller init container** + so `oras pull` sees credentials via `$DOCKER_CONFIG`. + +Multi-registry deployments: combine all credentials into a single +dockerconfigjson Secret. The operator only mounts the first listed +secret into the init container, so consolidating keeps things +predictable. + +## Re-pull semantics + +The skill-puller init container runs once per pod start. There is no +shared cache between pods. Two practical consequences: + +- **Pin to digests** in production. The registry can cheaply skip + identical content; mutable tags force a fresh pull each time. +- **Skill artifacts should be small**. They're text + examples; if + yours is hundreds of MB, something is off — most skills measure in + kilobytes. + +## Air-gapped clusters + +The default puller image is `ghcr.io/oras-project/oras:v1.2.0`. In +air-gapped or registry-restricted environments, mirror it to an +internal registry and tell the operator via the +`--skill-puller-image` flag: + +```yaml +# Helm values.yaml +manager: + skillPullerImage: registry.internal/oras:v1.2.0 +``` + +## Skill content patterns + +Some patterns we've found useful: + +- **State the agent's persona up front.** "You are a financial + analyst." Clear framing improves output quality. +- **Show, don't just tell.** A worked example in `examples/` is worth + ten paragraphs of "you should follow this structure." +- **Templates beat prose for output shape.** If the agent should + produce a structured artifact, ship a `templates/report.tmpl` with + placeholders and reference it from `SKILL.md`. + +See `config/samples/oci-skills-team.yaml` for a working team that uses +both public and private OCI skills. diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md index 743d5e7..7030f98 100644 --- a/docs/reference/api/index.md +++ b/docs/reference/api/index.md @@ -187,6 +187,7 @@ _Appears in:_ | `observability` _[ObservabilitySpec](#observabilityspec)_ | Observability configures metrics and notifications. | | Optional: \{\}
| | `pipeline` _[PipelineSpec](#pipelinespec)_ | Pipeline declares an ordered set of stages with explicit fan-out/merge
semantics. When set, the operator derives each teammate's effective
dependencies from the stage graph instead of the per-teammate DependsOn
field, which becomes mutually exclusive (enforced by CEL validation
on this spec). Inputs[].From still contributes regardless. | | Optional: \{\}
| | `harness` _string_ | Harness selects the agent runtime that powers this team's pods.
Today the only supported value is "claude-code" (Anthropic's native
Claude Code Agent Teams protocol), which is also the default when
omitted. The field exists so the operator's API stays neutral to a
single agent runtime; future harnesses for other team-based agent
systems can plug in behind the same CRD without an API break. | claude-code | Enum: [claude-code]
Optional: \{\}
| +| `imagePullSecrets` _[LocalObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#localobjectreference-v1-core) array_ | ImagePullSecrets are credentials for pulling private container
images, including OCI-distributed skills. The same secrets are
applied to agent pods (for the runner image) and to skill-puller
init containers (for pulling skill artifacts via ORAS). Use
kubernetes.io/dockerconfigjson Secrets — the operator mounts them
into the init container so ORAS can resolve registry credentials
from $DOCKER_CONFIG. | | Optional: \{\}
| #### AgentTeamStatus @@ -821,7 +822,13 @@ _Appears in:_ -SkillSource identifies where to load a skill from. Exactly one field should be set. +SkillSource identifies where to load a skill from. Exactly one of +ConfigMap or OCI must be set (enforced by CEL on SkillSpec). + +ConfigMap is simplest and lives entirely within the cluster — good +for skills authored alongside the team CRs. OCI distributes skills +as registry artifacts so they can be versioned, signed, shared +across clusters, and pulled from public or private registries. @@ -831,7 +838,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `configMap` _string_ | ConfigMap references a ConfigMap in the same namespace.
Each key in the ConfigMap becomes a file in the skill directory. | | Optional: \{\}
| -| `oci` _string_ | OCI is an OCI artifact reference containing the skill files (e.g. "ghcr.io/org/skills/web-research:v1"). | | Optional: \{\}
| +| `oci` _string_ | OCI is an OCI artifact reference containing the skill files
(e.g. "ghcr.io/org/skills/web-research:v1"). The operator runs
an `oras pull` init container at pod startup to materialize the
skill onto an emptyDir; the main container then sees the files
under ~/.claude/skills/\{name\}/. Private registries are supported
via spec.imagePullSecrets.
Re-pull semantics: the init container runs once per pod start,
so the artifact is re-pulled on every pod create. There is no
shared cache between pods — operators who want one should pin
to immutable digests so the registry can short-circuit identical
pulls cheaply. | | Optional: \{\}
| #### SkillSpec diff --git a/internal/controller/agentteam_controller.go b/internal/controller/agentteam_controller.go index 6d353c1..e6bcb7b 100644 --- a/internal/controller/agentteam_controller.go +++ b/internal/controller/agentteam_controller.go @@ -35,6 +35,12 @@ import ( const ( defaultInitImage = "alpine/git:latest" + + // defaultSkillPullerImage is the OCI artifact puller used to + // materialize skills declared with SkillSource.OCI. ORAS speaks + // the OCI distribution spec directly and supports artifacts that + // aren't container images, which is what skills are. + defaultSkillPullerImage = "ghcr.io/oras-project/oras:v1.2.0" ) // AgentTeamReconciler reconciles an AgentTeam object. @@ -49,6 +55,12 @@ type AgentTeamReconciler struct { // InitImage overrides the default alpine/git image used for the repo init Job. InitImage string + // SkillPullerImage overrides the default ORAS image used to pull + // OCI-distributed skills into agent pods. Tests can swap in a + // no-op image; air-gapped clusters can pin to an internally + // mirrored ORAS build. + SkillPullerImage string + // SkipInitScript replaces the init Job's git-clone script with a no-op (exit 0). // Used in acceptance tests where no real git repository is available. SkipInitScript bool @@ -153,6 +165,13 @@ func (r *AgentTeamReconciler) initImage() string { return defaultInitImage } +func (r *AgentTeamReconciler) skillPullerImage() string { + if r.SkillPullerImage != "" { + return r.SkillPullerImage + } + return defaultSkillPullerImage +} + // +kubebuilder:rbac:groups=kagents.dev,resources=agentteams,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=kagents.dev,resources=agentteams/status,verbs=get;update;patch // +kubebuilder:rbac:groups=kagents.dev,resources=agentteams/finalizers,verbs=update @@ -1297,26 +1316,84 @@ func (r *AgentTeamReconciler) buildAgentPod( } } - // Skills: each ConfigMap-backed skill gets mounted at /var/claude-skills/{name}/. - // The entrypoint copies them into ~/.claude/skills/{name}/. + // Skills: each skill is materialized at /var/claude-skills/{name}/; + // the runner entrypoint copies that directory into ~/.claude/skills/{name}/ + // before launching Claude Code. + // + // ConfigMap source: mount directly. Cheapest path — no network, no + // extra container, just a projected ConfigMap volume. + // + // OCI source: an init container pulls the artifact via `oras` into + // a per-skill emptyDir that the main container also mounts. Pull + // credentials come from spec.imagePullSecrets — the first listed + // kubernetes.io/dockerconfigjson Secret is projected at + // /auth/.docker/config.json and DOCKER_CONFIG points at it, which + // ORAS picks up natively. Multi-registry deployments combine creds + // into a single dockerconfigjson; this keeps the init container's + // auth surface to one mount. + var skillInitContainers []corev1.Container + needSkillAuth := false for _, skill := range skills { - if skill.Source.ConfigMap == "" { - continue // OCI not yet implemented. - } volName := "skill-" + skill.Name + switch { + case skill.Source.ConfigMap != "": + volumes = append(volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{Name: skill.Source.ConfigMap}, + }, + }, + }) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name), + ReadOnly: true, + }) + case skill.Source.OCI != "": + volumes = append(volumes, corev1.Volume{ + Name: volName, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + }) + volumeMounts = append(volumeMounts, corev1.VolumeMount{ + Name: volName, + MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name), + ReadOnly: true, + }) + initMounts := []corev1.VolumeMount{ + {Name: volName, MountPath: "/skill-out"}, + } + var initEnv []corev1.EnvVar + if len(team.Spec.ImagePullSecrets) > 0 { + needSkillAuth = true + initMounts = append(initMounts, corev1.VolumeMount{ + Name: "skill-auth", MountPath: "/auth/.docker", ReadOnly: true, + }) + initEnv = append(initEnv, corev1.EnvVar{Name: "DOCKER_CONFIG", Value: "/auth/.docker"}) + } + skillInitContainers = append(skillInitContainers, corev1.Container{ + Name: "pull-skill-" + skill.Name, + Image: r.skillPullerImage(), + Command: []string{"oras", "pull", "--output", "/skill-out", skill.Source.OCI}, + Env: initEnv, + VolumeMounts: initMounts, + }) + } + } + if needSkillAuth { + // Project the first imagePullSecret's .dockerconfigjson into a + // fixed path the init containers read. Items[] is explicit so + // the mounted filename is always "config.json", regardless of + // the Secret's key name preference. volumes = append(volumes, corev1.Volume{ - Name: volName, + Name: "skill-auth", VolumeSource: corev1.VolumeSource{ - ConfigMap: &corev1.ConfigMapVolumeSource{ - LocalObjectReference: corev1.LocalObjectReference{Name: skill.Source.ConfigMap}, + Secret: &corev1.SecretVolumeSource{ + SecretName: team.Spec.ImagePullSecrets[0].Name, + Items: []corev1.KeyToPath{{Key: ".dockerconfigjson", Path: "config.json"}}, }, }, }) - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: volName, - MountPath: fmt.Sprintf("/var/claude-skills/%s", skill.Name), - ReadOnly: true, - }) } // MCP config: mount the per-agent ConfigMap at /var/claude-mcp/mcp.json. @@ -1351,6 +1428,9 @@ func (r *AgentTeamReconciler) buildAgentPod( // staged. The implicit-dependency wiring in effectiveDependencies ensures // the producer has already reached Succeeded before this pod is created. var initContainers []corev1.Container + // Skill pulls run first so they're available before any input + // staging that might reference skill-produced configs. + initContainers = append(initContainers, skillInitContainers...) if team.Spec.Workspace != nil && team.Spec.Workspace.Output != nil && len(inputs) > 0 { outMountPath := team.Spec.Workspace.Output.MountPath if outMountPath == "" { @@ -1394,6 +1474,7 @@ func (r *AgentTeamReconciler) buildAgentPod( Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, ServiceAccountName: agentServiceAccountName(team, agentName), + ImagePullSecrets: team.Spec.ImagePullSecrets, InitContainers: initContainers, Containers: []corev1.Container{ { diff --git a/internal/controller/agentteam_ociskill_test.go b/internal/controller/agentteam_ociskill_test.go new file mode 100644 index 0000000..21031d4 --- /dev/null +++ b/internal/controller/agentteam_ociskill_test.go @@ -0,0 +1,168 @@ +package controller + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + + claudev1alpha1 "github.com/amcheste/kagents/api/v1alpha1" +) + +// findInitContainer returns the first init container whose name starts +// with the given prefix, or nil. The init-container set on a Pod is +// order-dependent (k8s runs them sequentially) but tests should index +// by name rather than position so future ordering changes don't break +// every assertion. +func findInitContainer(pod *corev1.Pod, prefix string) *corev1.Container { + for i := range pod.Spec.InitContainers { + if strings.HasPrefix(pod.Spec.InitContainers[i].Name, prefix) { + return &pod.Spec.InitContainers[i] + } + } + return nil +} + +// findVolume returns the first Volume by name, or nil. +func findVolume(pod *corev1.Pod, name string) *corev1.Volume { + for i := range pod.Spec.Volumes { + if pod.Spec.Volumes[i].Name == name { + return &pod.Spec.Volumes[i] + } + } + return nil +} + +func TestBuildAgentPod_OCISkill_ProducesInitContainerAndEmptyDir(t *testing.T) { + t.Parallel() + team := minimalTeam("oci-skills") + r := newReconciler(team) + skills := []claudev1alpha1.SkillSpec{ + {Name: "web-research", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/acme/skills/web-research:v1"}}, + } + + pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false, + corev1.ResourceRequirements{}, nil, skills, nil, nil) + + // EmptyDir holds the pulled artifact between the init container and + // the main container — read-only on the main side, RW on init. + v := findVolume(pod, "skill-web-research") + require.NotNil(t, v, "skill-web-research volume must exist") + assert.NotNil(t, v.EmptyDir, "OCI skill volume must be emptyDir") + assert.Nil(t, v.ConfigMap, "OCI skill volume must not be a ConfigMap projection") + + // Init container runs `oras pull` into /skill-out, which is the + // pod-side view of the emptyDir. + ic := findInitContainer(pod, "pull-skill-web-research") + require.NotNil(t, ic, "pull-skill init container must exist") + assert.Equal(t, defaultSkillPullerImage, ic.Image) + assert.Contains(t, ic.Command, "oras") + assert.Contains(t, ic.Command, "ghcr.io/acme/skills/web-research:v1") + + // No registry creds configured → no DOCKER_CONFIG and no auth volume. + for _, e := range ic.Env { + assert.NotEqual(t, "DOCKER_CONFIG", e.Name, "no auth secret = no DOCKER_CONFIG") + } + assert.Nil(t, findVolume(pod, "skill-auth"), "no auth secret = no skill-auth volume") +} + +func TestBuildAgentPod_OCISkill_PrivateRegistryWiresAuth(t *testing.T) { + t.Parallel() + team := minimalTeam("oci-private") + team.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "ghcr-creds"}} + r := newReconciler(team) + skills := []claudev1alpha1.SkillSpec{ + {Name: "internal", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/acme-internal/skills/secret:v3"}}, + } + + pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false, + corev1.ResourceRequirements{}, nil, skills, nil, nil) + + // Pod-level imagePullSecrets propagated so the kubelet can pull + // both the puller image and any private agent images. + require.Len(t, pod.Spec.ImagePullSecrets, 1) + assert.Equal(t, "ghcr-creds", pod.Spec.ImagePullSecrets[0].Name) + + // Auth volume projects .dockerconfigjson → config.json. + authVol := findVolume(pod, "skill-auth") + require.NotNil(t, authVol) + require.NotNil(t, authVol.Secret) + assert.Equal(t, "ghcr-creds", authVol.Secret.SecretName) + require.Len(t, authVol.Secret.Items, 1) + assert.Equal(t, ".dockerconfigjson", authVol.Secret.Items[0].Key) + assert.Equal(t, "config.json", authVol.Secret.Items[0].Path) + + // Init container picks up DOCKER_CONFIG so ORAS finds creds. + ic := findInitContainer(pod, "pull-skill-internal") + require.NotNil(t, ic) + var dockerConfig string + for _, e := range ic.Env { + if e.Name == "DOCKER_CONFIG" { + dockerConfig = e.Value + } + } + assert.Equal(t, "/auth/.docker", dockerConfig) + var mountedAuth bool + for _, m := range ic.VolumeMounts { + if m.Name == "skill-auth" { + mountedAuth = true + assert.Equal(t, "/auth/.docker", m.MountPath) + assert.True(t, m.ReadOnly) + } + } + assert.True(t, mountedAuth, "skill-auth must be mounted into the puller init container") +} + +func TestBuildAgentPod_ConfigMapSkill_StillProjectsDirectly(t *testing.T) { + t.Parallel() + team := minimalTeam("cm-skills") + r := newReconciler(team) + skills := []claudev1alpha1.SkillSpec{ + {Name: "report-writing", Source: claudev1alpha1.SkillSource{ConfigMap: "report-skill"}}, + } + + pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false, + corev1.ResourceRequirements{}, nil, skills, nil, nil) + + v := findVolume(pod, "skill-report-writing") + require.NotNil(t, v) + require.NotNil(t, v.ConfigMap, "ConfigMap skill must use a ConfigMap volume") + assert.Equal(t, "report-skill", v.ConfigMap.Name) + + assert.Nil(t, findInitContainer(pod, "pull-skill-"), "ConfigMap skills should not produce a puller init container") +} + +func TestBuildAgentPod_MixedSkills(t *testing.T) { + t.Parallel() + team := minimalTeam("mixed-skills") + r := newReconciler(team) + skills := []claudev1alpha1.SkillSpec{ + {Name: "config-skill", Source: claudev1alpha1.SkillSource{ConfigMap: "cm"}}, + {Name: "oci-skill", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/x/y:1"}}, + } + + pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false, + corev1.ResourceRequirements{}, nil, skills, nil, nil) + + require.NotNil(t, findVolume(pod, "skill-config-skill")) + require.NotNil(t, findVolume(pod, "skill-oci-skill")) + require.NotNil(t, findInitContainer(pod, "pull-skill-oci-skill")) + assert.Nil(t, findInitContainer(pod, "pull-skill-config-skill")) +} + +func TestBuildAgentPod_OCISkill_PullerImageOverride(t *testing.T) { + t.Parallel() + team := minimalTeam("override-image") + r := newReconciler(team) + r.SkillPullerImage = "my-mirror.example.com/oras:v1.2.0" + skills := []claudev1alpha1.SkillSpec{ + {Name: "x", Source: claudev1alpha1.SkillSource{OCI: "ghcr.io/x/x:1"}}, + } + pod := r.buildAgentPod(team, "worker", "sonnet", "do work", "auto-accept", false, + corev1.ResourceRequirements{}, nil, skills, nil, nil) + ic := findInitContainer(pod, "pull-skill-x") + require.NotNil(t, ic) + assert.Equal(t, "my-mirror.example.com/oras:v1.2.0", ic.Image) +} diff --git a/mkdocs.yml b/mkdocs.yml index f87701f..cce420c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -108,6 +108,8 @@ nav: - Expose the dashboard: how-to/operate/expose-dashboard.md - Configure shared storage: how-to/operate/shared-storage.md - Set budget alerts: how-to/operate/budget-alerts.md + - Author skills: + - Package and publish skills: how-to/skill-authoring.md - Reference: - reference/index.md - API reference: reference/api/index.md