diff --git a/pkg/devcontainer/config/feature.go b/pkg/devcontainer/config/feature.go index 8dabb6a10..b06c948e8 100644 --- a/pkg/devcontainer/config/feature.go +++ b/pkg/devcontainer/config/feature.go @@ -73,6 +73,9 @@ type FeatureConfig struct { // Lifecycle hooks DevContainerActions `json:",inline"` + // OCI manifest annotations (populated only for OCI-sourced features). + Annotations map[string]string `json:"-"` + // Origin is the path where the feature was loaded from Origin string `json:"-"` } diff --git a/pkg/devcontainer/feature/annotations_test.go b/pkg/devcontainer/feature/annotations_test.go new file mode 100644 index 000000000..043c7f0e8 --- /dev/null +++ b/pkg/devcontainer/feature/annotations_test.go @@ -0,0 +1,103 @@ +package feature + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSaveAnnotations(t *testing.T) { + dir := t.TempDir() + annotations := map[string]string{ + "org.opencontainers.image.title": "Go", + "org.opencontainers.image.description": "Installs Go and common Go tools", + "org.opencontainers.image.version": "1.2.3", + "org.opencontainers.image.source": "https://github.com/devcontainers/features", + } + + saveAnnotations(dir, annotations) + + data, err := os.ReadFile(filepath.Clean(filepath.Join(dir, annotationsFileName))) + require.NoError(t, err) + + var loaded map[string]string + require.NoError(t, json.Unmarshal(data, &loaded)) + assert.Equal(t, annotations, loaded) +} + +func TestSaveAnnotations_EmptyMap(t *testing.T) { + dir := t.TempDir() + saveAnnotations(dir, map[string]string{}) + + data, err := os.ReadFile(filepath.Clean(filepath.Join(dir, annotationsFileName))) + require.NoError(t, err) + + var loaded map[string]string + require.NoError(t, json.Unmarshal(data, &loaded)) + assert.Empty(t, loaded) +} + +func TestLoadOCIAnnotations_Present(t *testing.T) { + dir := t.TempDir() + extractedDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(extractedDir, 0o750)) + + annotations := map[string]string{ + "org.opencontainers.image.title": "Node.js", + "org.opencontainers.image.description": "Installs Node.js and common npm tools", + "org.opencontainers.image.authors": "Dev Containers", + "org.opencontainers.image.url": "https://github.com/devcontainers/features/tree/main/src/node", + "org.opencontainers.image.documentation": "https://containers.dev/features", + "org.opencontainers.image.licenses": "MIT", + "dev.containers.metadata": `{"id":"node"}`, + } + saveAnnotations(dir, annotations) + + loaded := LoadOCIAnnotations(extractedDir) + assert.Equal(t, annotations, loaded) +} + +func TestLoadOCIAnnotations_Missing(t *testing.T) { + dir := t.TempDir() + extractedDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(extractedDir, 0o750)) + + loaded := LoadOCIAnnotations(extractedDir) + assert.Nil(t, loaded) +} + +func TestLoadOCIAnnotations_InvalidJSON(t *testing.T) { + dir := t.TempDir() + extractedDir := filepath.Join(dir, "extracted") + require.NoError(t, os.MkdirAll(extractedDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, annotationsFileName), + []byte("not valid json"), + 0o600, + )) + + loaded := LoadOCIAnnotations(extractedDir) + assert.Nil(t, loaded) +} + +func TestLogOCIAnnotations_NoTitle(t *testing.T) { + annotations := map[string]string{ + "org.opencontainers.image.source": "https://github.com/example/features", + } + // Should not panic with missing title/description + logOCIAnnotations("ghcr.io/example/feature:1", annotations) +} + +func TestLogOCIAnnotations_WithTitle(t *testing.T) { + annotations := map[string]string{ + "org.opencontainers.image.title": "Go", + "org.opencontainers.image.description": "Installs Go", + "org.opencontainers.image.version": "1.0.0", + } + // Should not panic + logOCIAnnotations("ghcr.io/devcontainers/features/go:1", annotations) +} diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 99039ca34..8726603eb 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -353,6 +353,10 @@ func (p *featureProcessor) processFeature( return nil, fmt.Errorf("parse feature: %w", err) } + if annotations := LoadOCIAnnotations(featureFolder); annotations != nil { + featureConfig.Annotations = annotations + } + if err := ValidateFeatureOptions(featureID, featureConfig, featureOptions); err != nil { return nil, err } diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index 828441694..dc9919973 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -1,6 +1,7 @@ package feature import ( + "encoding/json" "fmt" "io" "net/http" @@ -211,10 +212,16 @@ func processOCIFeature(id string) (string, error) { return "", err } - if err := pullAndExtractOCIFeature(ref, id, featureFolder, featureExtractedFolder); err != nil { + annotations, err := pullAndExtractOCIFeature(ref, id, featureFolder, featureExtractedFolder) + if err != nil { return "", err } + if len(annotations) > 0 { + logOCIAnnotations(id, annotations) + saveAnnotations(featureFolder, annotations) + } + log.Infof( "OCI feature processed successfully: featureId=%s, path=%s", id, @@ -223,10 +230,64 @@ func processOCIFeature(id string) (string, error) { return featureExtractedFolder, nil } -func pullAndExtractOCIFeature(ref name.Reference, id, featureFolder, destDir string) error { +const annotationsFileName = "annotations.json" + +func logOCIAnnotations(id string, annotations map[string]string) { + title := annotations["org.opencontainers.image.title"] + description := annotations["org.opencontainers.image.description"] + version := annotations["org.opencontainers.image.version"] + + if title != "" || description != "" { + log.Infof( + "Feature %q: title=%q, description=%q, version=%q", + id, title, description, version, + ) + } + + for key, value := range annotations { + log.Debugf("OCI annotation: featureId=%s, %s=%s", id, key, value) + } +} + +func saveAnnotations(featureFolder string, annotations map[string]string) { + data, err := json.Marshal(annotations) + if err != nil { + log.Debugf("failed to marshal annotations: %v", err) + return + } + filePath := filepath.Join(featureFolder, annotationsFileName) + if err := os.WriteFile(filePath, data, 0o600); err != nil { + log.Debugf("failed to write annotations sidecar: %v", err) + } +} + +func LoadOCIAnnotations(featureFolder string) map[string]string { + parentDir := filepath.Dir(featureFolder) + filePath := filepath.Join(parentDir, annotationsFileName) + data, err := os.ReadFile(filepath.Clean(filePath)) + if err != nil { + return nil + } + var annotations map[string]string + if err := json.Unmarshal(data, &annotations); err != nil { + log.Debugf("failed to parse annotations sidecar: %v", err) + return nil + } + return annotations +} + +func pullAndExtractOCIFeature( + ref name.Reference, + id, featureFolder, destDir string, +) (map[string]string, error) { img, err := pullOCIImage(ref) if err != nil { - return err + return nil, err + } + + manifest, err := img.Manifest() + if err != nil { + return nil, fmt.Errorf("read manifest: %w", err) } destFile := filepath.Join(featureFolder, "feature.tgz") @@ -234,12 +295,12 @@ func pullAndExtractOCIFeature(ref name.Reference, id, featureFolder, destDir str err = downloadLayer(img, id, destFile) if err != nil { log.Debugf("failed to download feature layer: error=%v, featureId=%s", err, id) - return fmt.Errorf("download layer from %s: %w", registry, err) + return nil, fmt.Errorf("download layer from %s: %w", registry, err) } file, err := os.Open(destFile) if err != nil { - return err + return nil, err } defer func() { _ = file.Close() }() @@ -248,10 +309,10 @@ func pullAndExtractOCIFeature(ref name.Reference, id, featureFolder, destDir str if err != nil { log.Debugf("failed to extract feature: error=%v, destination=%s", err, destDir) _ = os.RemoveAll(destDir) - return err + return nil, err } - return nil + return manifest.Annotations, nil } func validateImageManifest(img v1.Image) (*v1.Manifest, error) {