|
| 1 | +// SPDX-FileCopyrightText: Copyright 2026 Stacklok, Inc. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +package skills |
| 5 | + |
| 6 | +import ( |
| 7 | + "encoding/json" |
| 8 | + "fmt" |
| 9 | + "strings" |
| 10 | +) |
| 11 | + |
| 12 | +// Artifact type for skill identification. |
| 13 | +const ( |
| 14 | + // ArtifactTypeSkill identifies skill artifacts in manifests. |
| 15 | + ArtifactTypeSkill = "dev.toolhive.skills.v1" |
| 16 | +) |
| 17 | + |
| 18 | +// OCI Image Index media type. |
| 19 | +const ( |
| 20 | + // MediaTypeImageIndex is the OCI image index media type. |
| 21 | + MediaTypeImageIndex = "application/vnd.oci.image.index.v1+json" |
| 22 | +) |
| 23 | + |
| 24 | +// Standard OCI media types for Kubernetes image volume compatibility. |
| 25 | +const ( |
| 26 | + // MediaTypeImageManifest is the OCI image manifest media type. |
| 27 | + MediaTypeImageManifest = "application/vnd.oci.image.manifest.v1+json" |
| 28 | + |
| 29 | + // MediaTypeImageConfig is the standard OCI image config media type. |
| 30 | + MediaTypeImageConfig = "application/vnd.oci.image.config.v1+json" |
| 31 | + |
| 32 | + // MediaTypeImageLayer is the standard OCI image layer media type. |
| 33 | + MediaTypeImageLayer = "application/vnd.oci.image.layer.v1.tar+gzip" |
| 34 | +) |
| 35 | + |
| 36 | +// Annotation keys for skill metadata in manifests. |
| 37 | +const ( |
| 38 | + // AnnotationCreated is the OCI standard annotation for creation time. |
| 39 | + AnnotationCreated = "org.opencontainers.image.created" |
| 40 | + |
| 41 | + // AnnotationSkillName is the annotation key for skill name. |
| 42 | + AnnotationSkillName = "dev.toolhive.skills.name" |
| 43 | + |
| 44 | + // AnnotationSkillDescription is the annotation key for skill description. |
| 45 | + AnnotationSkillDescription = "dev.toolhive.skills.description" |
| 46 | + |
| 47 | + // AnnotationSkillVersion is the annotation key for skill version. |
| 48 | + AnnotationSkillVersion = "dev.toolhive.skills.version" |
| 49 | + |
| 50 | + // AnnotationSkillRequires is the annotation key for skill external dependencies (JSON array of OCI references). |
| 51 | + AnnotationSkillRequires = "dev.toolhive.skills.requires" |
| 52 | +) |
| 53 | + |
| 54 | +// Label keys for skill metadata in OCI image config. |
| 55 | +const ( |
| 56 | + // LabelSkillName is the label key for skill name. |
| 57 | + LabelSkillName = "dev.toolhive.skills.name" |
| 58 | + |
| 59 | + // LabelSkillDescription is the label key for skill description. |
| 60 | + LabelSkillDescription = "dev.toolhive.skills.description" |
| 61 | + |
| 62 | + // LabelSkillVersion is the label key for skill version. |
| 63 | + LabelSkillVersion = "dev.toolhive.skills.version" |
| 64 | + |
| 65 | + // LabelSkillAllowedTools is the label key for allowed tools (JSON array). |
| 66 | + LabelSkillAllowedTools = "dev.toolhive.skills.allowedTools" |
| 67 | + |
| 68 | + // LabelSkillLicense is the label key for skill license. |
| 69 | + LabelSkillLicense = "dev.toolhive.skills.license" |
| 70 | + |
| 71 | + // LabelSkillFiles is the label key for skill files (JSON array). |
| 72 | + LabelSkillFiles = "dev.toolhive.skills.files" |
| 73 | +) |
| 74 | + |
| 75 | +// SkillConfig represents skill metadata extracted from OCI image config labels. |
| 76 | +type SkillConfig struct { |
| 77 | + Name string `json:"name"` |
| 78 | + Description string `json:"description"` |
| 79 | + Version string `json:"version,omitempty"` |
| 80 | + AllowedTools []string `json:"allowedTools,omitempty"` |
| 81 | + License string `json:"license,omitempty"` |
| 82 | + Compatibility string `json:"compatibility,omitempty"` |
| 83 | + Metadata map[string]string `json:"metadata,omitempty"` |
| 84 | + Files []string `json:"files"` |
| 85 | +} |
| 86 | + |
| 87 | +// ImageConfig represents a standard OCI image configuration. |
| 88 | +// This structure is required for Kubernetes image volume compatibility. |
| 89 | +type ImageConfig struct { |
| 90 | + Architecture string `json:"architecture"` |
| 91 | + OS string `json:"os"` |
| 92 | + Config ImageConfigData `json:"config,omitempty"` |
| 93 | + RootFS RootFS `json:"rootfs"` |
| 94 | + History []HistoryEntry `json:"history,omitempty"` |
| 95 | +} |
| 96 | + |
| 97 | +// ImageConfigData contains container configuration including labels. |
| 98 | +type ImageConfigData struct { |
| 99 | + Labels map[string]string `json:"Labels,omitempty"` |
| 100 | +} |
| 101 | + |
| 102 | +// RootFS describes the rootfs of the image. |
| 103 | +type RootFS struct { |
| 104 | + Type string `json:"type"` |
| 105 | + DiffIDs []string `json:"diff_ids"` |
| 106 | +} |
| 107 | + |
| 108 | +// HistoryEntry describes a layer in the image history. |
| 109 | +type HistoryEntry struct { |
| 110 | + Created string `json:"created,omitempty"` |
| 111 | + CreatedBy string `json:"created_by,omitempty"` |
| 112 | +} |
| 113 | + |
| 114 | +// SkillConfigFromImageConfig extracts SkillConfig from OCI image config labels. |
| 115 | +func SkillConfigFromImageConfig(imgConfig *ImageConfig) (*SkillConfig, error) { |
| 116 | + if imgConfig == nil { |
| 117 | + return nil, fmt.Errorf("image config is nil") |
| 118 | + } |
| 119 | + |
| 120 | + labels := imgConfig.Config.Labels |
| 121 | + if labels == nil { |
| 122 | + return nil, fmt.Errorf("oci config has no labels") |
| 123 | + } |
| 124 | + |
| 125 | + config := &SkillConfig{ |
| 126 | + Name: labels[LabelSkillName], |
| 127 | + Description: labels[LabelSkillDescription], |
| 128 | + Version: labels[LabelSkillVersion], |
| 129 | + License: labels[LabelSkillLicense], |
| 130 | + } |
| 131 | + |
| 132 | + if config.Name == "" { |
| 133 | + return nil, fmt.Errorf("skill name is required in labels") |
| 134 | + } |
| 135 | + |
| 136 | + // Parse JSON-encoded arrays |
| 137 | + if toolsJSON := labels[LabelSkillAllowedTools]; toolsJSON != "" { |
| 138 | + if err := json.Unmarshal([]byte(toolsJSON), &config.AllowedTools); err != nil { |
| 139 | + return nil, fmt.Errorf("parsing allowed tools: %w", err) |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + if filesJSON := labels[LabelSkillFiles]; filesJSON != "" { |
| 144 | + if err := json.Unmarshal([]byte(filesJSON), &config.Files); err != nil { |
| 145 | + return nil, fmt.Errorf("parsing files: %w", err) |
| 146 | + } |
| 147 | + } |
| 148 | + |
| 149 | + return config, nil |
| 150 | +} |
| 151 | + |
| 152 | +// Platform represents a target platform for OCI artifacts. |
| 153 | +type Platform struct { |
| 154 | + Architecture string `json:"architecture"` |
| 155 | + OS string `json:"os"` |
| 156 | +} |
| 157 | + |
| 158 | +// String returns the platform in "os/arch" format. |
| 159 | +func (p Platform) String() string { |
| 160 | + return p.OS + "/" + p.Architecture |
| 161 | +} |
| 162 | + |
| 163 | +// ParsePlatform parses a platform string in "os/arch" format. |
| 164 | +func ParsePlatform(s string) (Platform, error) { |
| 165 | + parts := strings.Split(s, "/") |
| 166 | + if len(parts) != 2 { |
| 167 | + return Platform{}, fmt.Errorf("invalid platform format: %q (expected os/arch)", s) |
| 168 | + } |
| 169 | + osName := strings.TrimSpace(parts[0]) |
| 170 | + arch := strings.TrimSpace(parts[1]) |
| 171 | + if osName == "" || arch == "" { |
| 172 | + return Platform{}, fmt.Errorf("invalid platform format: %q (os and arch cannot be empty)", s) |
| 173 | + } |
| 174 | + return Platform{OS: osName, Architecture: arch}, nil |
| 175 | +} |
| 176 | + |
| 177 | +// DefaultPlatforms are the default platforms for skill artifacts. |
| 178 | +// These cover most Kubernetes clusters. |
| 179 | +var DefaultPlatforms = []Platform{ |
| 180 | + {OS: "linux", Architecture: "amd64"}, |
| 181 | + {OS: "linux", Architecture: "arm64"}, |
| 182 | +} |
| 183 | + |
| 184 | +// ImageIndex represents an OCI image index (multi-platform manifest list). |
| 185 | +type ImageIndex struct { |
| 186 | + SchemaVersion int `json:"schemaVersion"` |
| 187 | + MediaType string `json:"mediaType"` |
| 188 | + ArtifactType string `json:"artifactType,omitempty"` |
| 189 | + Manifests []IndexDescriptor `json:"manifests"` |
| 190 | + Annotations map[string]string `json:"annotations,omitempty"` |
| 191 | +} |
| 192 | + |
| 193 | +// IndexDescriptor describes a manifest in an image index. |
| 194 | +type IndexDescriptor struct { |
| 195 | + MediaType string `json:"mediaType"` |
| 196 | + Digest string `json:"digest"` |
| 197 | + Size int64 `json:"size"` |
| 198 | + Platform *Platform `json:"platform,omitempty"` |
| 199 | + Annotations map[string]string `json:"annotations,omitempty"` |
| 200 | +} |
| 201 | + |
| 202 | +// ParseRequiresAnnotation parses skill dependency references from manifest annotations. |
| 203 | +// Returns nil if the annotation is missing or invalid. |
| 204 | +func ParseRequiresAnnotation(annotations map[string]string) []string { |
| 205 | + requiresJSON := annotations[AnnotationSkillRequires] |
| 206 | + if requiresJSON == "" { |
| 207 | + return nil |
| 208 | + } |
| 209 | + |
| 210 | + var refs []string |
| 211 | + if err := json.Unmarshal([]byte(requiresJSON), &refs); err != nil { |
| 212 | + // Invalid annotation format - return nil rather than propagating error |
| 213 | + // since annotations may come from older versions or external sources |
| 214 | + return nil |
| 215 | + } |
| 216 | + return refs |
| 217 | +} |
0 commit comments