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
5 changes: 5 additions & 0 deletions pkg/devcontainer/config/extends.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ func resolveExtendsArray(
}

// resolveExtendsSingle resolves a single extends reference.
// It dispatches to OCI resolution for registry refs or local file resolution otherwise.
func resolveExtendsSingle(
extendsRef, declaringDir string,
visited map[string]bool,
) (*DevContainerConfig, error) {
if isOCIRef(extendsRef) {
return resolveOCIExtends(extendsRef, visited)
}

refPath := extendsRef
if !filepath.IsAbs(refPath) {
refPath = filepath.Join(declaringDir, refPath)
Expand Down
176 changes: 176 additions & 0 deletions pkg/devcontainer/config/extends_oci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package config

import (
"archive/tar"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/remote/transport"
"github.com/tailscale/hujson"
"k8s.io/apimachinery/pkg/util/wait"
)

var ociExtendsBackoff = wait.Backoff{
Duration: 1 * time.Second,
Factor: 2.0,
Steps: 3,
}

// isOCIRef returns true if the extends reference looks like an OCI image ref
// (e.g. "ghcr.io/owner/repo:tag") rather than a local file path.
func isOCIRef(ref string) bool {
if strings.HasPrefix(ref, ".") || strings.HasPrefix(ref, "/") {
return false
}
if strings.HasSuffix(ref, ".json") || strings.HasSuffix(ref, ".jsonc") {
return false
}
return strings.Contains(ref, "/")
}

// resolveOCIExtends fetches a devcontainer.json from an OCI artifact and
// recursively resolves any extends within it.
func resolveOCIExtends(
ociRef string,
visited map[string]bool,
) (*DevContainerConfig, error) {
if visited[ociRef] {
return nil, fmt.Errorf("extends: cycle detected, OCI ref %q already in chain", ociRef)
}
visited[ociRef] = true

data, err := pullOCIExtendsJSON(ociRef)
if err != nil {
return nil, fmt.Errorf("extends: fetch OCI %q: %w", ociRef, err)
}

devContainer := &DevContainerConfig{}
normalized, err := hujson.Standardize(data)
if err != nil {
return nil, fmt.Errorf("extends: parse jsonc from OCI %q: %w", ociRef, err)
}
if err := json.Unmarshal(normalized, devContainer); err != nil {
return nil, fmt.Errorf("extends: unmarshal OCI %q: %w", ociRef, err)
}
devContainer.Origin = "oci://" + ociRef

if !devContainer.Extends.IsEmpty() {
parent, err := resolveExtendsArray(devContainer.Extends, "", visited)
if err != nil {
return nil, err
}
devContainer = mergeExtendsConfigs(parent, devContainer)
}

return devContainer, nil
}

// pullOCIExtendsJSON fetches an OCI image and extracts devcontainer.json
// from its first layer (expected to be a gzipped tarball).
func pullOCIExtendsJSON(ociRef string) ([]byte, error) {
ref, err := name.ParseReference(ociRef)
if err != nil {
return nil, fmt.Errorf("parse reference: %w", err)
}

var img v1.Image
err = retryOCIExtendsPull(func() error {
var fetchErr error
img, fetchErr = remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain))
return fetchErr
})
if err != nil {
return nil, fmt.Errorf("pull image: %w", err)
}

return extractDevContainerJSON(img)
}

// extractDevContainerJSON reads the first layer of an OCI image as a
// gzipped tarball and returns the contents of devcontainer.json.
func extractDevContainerJSON(img v1.Image) ([]byte, error) {
layers, err := img.Layers()
if err != nil {
return nil, fmt.Errorf("get layers: %w", err)
}
if len(layers) == 0 {
return nil, errors.New("OCI image has no layers")
}

rc, err := layers[0].Compressed()
if err != nil {
return nil, fmt.Errorf("read layer: %w", err)
}
defer func() { _ = rc.Close() }()

return findDevContainerInGzip(rc)
}

func findDevContainerInGzip(rc io.Reader) ([]byte, error) {
gz, err := gzip.NewReader(rc)
if err != nil {
return nil, fmt.Errorf("decompress layer: %w", err)
}
defer func() { _ = gz.Close() }()

tr := tar.NewReader(gz)
for {
hdr, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read tar: %w", err)
}

base := strings.TrimPrefix(hdr.Name, "./")
if base == "devcontainer.json" || base == ".devcontainer.json" {
data, err := io.ReadAll(tr)
if err != nil {
return nil, fmt.Errorf("read devcontainer.json from tar: %w", err)
}
return data, nil
}
}

return nil, errors.New("devcontainer.json not found in OCI layer")
}

func retryOCIExtendsPull(fn func() error) error {
var lastErr error
err := wait.ExponentialBackoff(ociExtendsBackoff, func() (bool, error) {
lastErr = fn()
if lastErr == nil {
return true, nil
}
if !isOCIExtendsTransientError(lastErr) {
return false, lastErr
}
return false, nil
})
if wait.Interrupted(err) {
return lastErr
}
return err
}

func isOCIExtendsTransientError(err error) bool {
if err == nil {
return false
}
var terr *transport.Error
if errors.As(err, &terr) {
return terr.StatusCode >= http.StatusInternalServerError
}
return true
}
189 changes: 189 additions & 0 deletions pkg/devcontainer/config/extends_oci_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package config

import (
"archive/tar"
"bytes"
"compress/gzip"
"net/http/httptest"
"strings"
"testing"

"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/fake"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/static"
"github.com/google/go-containerregistry/pkg/v1/types"
)

func TestIsOCIRef(t *testing.T) {
tests := []struct {
ref string
want bool
}{
{"ghcr.io/owner/repo:tag", true},
{"docker.io/library/ubuntu:latest", true},
{"myregistry.com/org/devcontainer-base:1", true},
{"./base.json", false},
{"../shared/base.json", false},
{"/absolute/path.json", false},
{"base.json", false},
{"relative/path.json", false},
{"relative/path.jsonc", false},
}
for _, tc := range tests {
t.Run(tc.ref, func(t *testing.T) {
got := isOCIRef(tc.ref)
if got != tc.want {
t.Errorf("isOCIRef(%q) = %v, want %v", tc.ref, got, tc.want)
}
})
}
}

func TestExtractDevContainerJSON(t *testing.T) {
content := `{"name": "from-oci", "image": "ubuntu:22.04"}`
img := createFakeImageWithJSON(t, "devcontainer.json", content)

data, err := extractDevContainerJSON(img)
if err != nil {
t.Fatal(err)
}
if string(data) != content {
t.Errorf("got %q, want %q", string(data), content)
}
}

func TestExtractDevContainerJSON_PrefixedPath(t *testing.T) {
content := `{"name": "prefixed"}`
img := createFakeImageWithJSON(t, "./devcontainer.json", content)

data, err := extractDevContainerJSON(img)
if err != nil {
t.Fatal(err)
}
if string(data) != content {
t.Errorf("got %q, want %q", string(data), content)
}
}

func TestExtractDevContainerJSON_NotFound(t *testing.T) {
img := createFakeImageWithJSON(t, "other-file.txt", "hello")

_, err := extractDevContainerJSON(img)
if err == nil {
t.Fatal("expected error for missing devcontainer.json")
}
}

func TestResolveOCIExtends_Integration(t *testing.T) {
srv := httptest.NewServer(registry.New())
defer srv.Close()

regHost := strings.TrimPrefix(srv.URL, "http://")

jsonContent := `{
"name": "oci-parent",
"image": "ubuntu:22.04",
"remoteUser": "vscode",
"containerEnv": {"FROM_OCI": "oci-value"}
}`

pushTestImage(t, regHost+"/test/devcontainer-base:latest", jsonContent)

visited := map[string]bool{}
cfg, err := resolveOCIExtends(regHost+"/test/devcontainer-base:latest", visited)
if err != nil {
t.Fatal(err)
}
if cfg.Name != "oci-parent" {
t.Errorf("Name: got %q, want 'oci-parent'", cfg.Name)
}
if cfg.Image != "ubuntu:22.04" {
t.Errorf("Image: got %q, want 'ubuntu:22.04'", cfg.Image)
}
if cfg.RemoteUser != testUserVscode {
t.Errorf("RemoteUser: got %q, want %q", cfg.RemoteUser, testUserVscode)
}
if cfg.ContainerEnv["FROM_OCI"] != "oci-value" {
t.Error("missing FROM_OCI env var")
}
}

func TestResolveOCIExtends_CycleDetection(t *testing.T) {
ref := "ghcr.io/fake/cycle:1"
visited := map[string]bool{ref: true}

_, err := resolveOCIExtends(ref, visited)
if err == nil {
t.Fatal("expected cycle error")
}
if !strings.Contains(err.Error(), "cycle") {
t.Errorf("expected 'cycle' in error, got: %v", err)
}
}

func pushTestImage(t *testing.T, refStr, jsonContent string) {
t.Helper()

layer := static.NewLayer(
buildTarGz(t, "devcontainer.json", jsonContent),
types.OCILayer,
)

img, err := mutate.AppendLayers(empty.Image, layer)
if err != nil {
t.Fatal(err)
}

ref, err := name.ParseReference(refStr, name.Insecure)
if err != nil {
t.Fatal(err)
}

if err := remote.Write(ref, img); err != nil {
t.Fatal(err)
}
}

func buildTarGz(t *testing.T, filename, content string) []byte {
t.Helper()

var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)

hdr := &tar.Header{
Name: filename,
Mode: 0o644,
Size: int64(len(content)),
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}

func createFakeImageWithJSON(t *testing.T, filename, content string) v1.Image {
t.Helper()

layer := static.NewLayer(buildTarGz(t, filename, content), types.OCILayer)

return &fake.FakeImage{
LayersStub: func() ([]v1.Layer, error) {
return []v1.Layer{layer}, nil
},
}
}
Loading
Loading