Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/devcontainer/config/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
}
Expand Down
103 changes: 103 additions & 0 deletions pkg/devcontainer/feature/annotations_test.go
Original file line number Diff line number Diff line change
@@ -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",

Check failure on line 16 in pkg/devcontainer/feature/annotations_test.go

View workflow job for this annotation

GitHub Actions / Lint

string `org.opencontainers.image.title` has 3 occurrences, make it a constant (goconst)

Check failure on line 16 in pkg/devcontainer/feature/annotations_test.go

View workflow job for this annotation

GitHub Actions / Lint Gate

string `org.opencontainers.image.title` has 3 occurrences, make it a constant (goconst)
"org.opencontainers.image.description": "Installs Go and common Go tools",

Check failure on line 17 in pkg/devcontainer/feature/annotations_test.go

View workflow job for this annotation

GitHub Actions / Lint

string `org.opencontainers.image.description` has 3 occurrences, make it a constant (goconst)

Check failure on line 17 in pkg/devcontainer/feature/annotations_test.go

View workflow job for this annotation

GitHub Actions / Lint Gate

string `org.opencontainers.image.description` has 3 occurrences, make it a constant (goconst)
"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)
}
4 changes: 4 additions & 0 deletions pkg/devcontainer/feature/extend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
75 changes: 68 additions & 7 deletions pkg/devcontainer/feature/features.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package feature

import (
"encoding/json"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -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,
Expand All @@ -223,23 +230,77 @@ 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")
registry := sanitizeURL(ref.Context().RegistryStr())
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() }()

Expand All @@ -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) {
Expand Down
Loading