From 488416cd88e80d0b21a72709d501ccd6cbba89a0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 3 May 2026 22:08:52 -0500 Subject: [PATCH] feat(config): support OCI remote extends for devcontainer.json Extends references that look like OCI image refs (e.g. "ghcr.io/org/base:1") are now pulled from the registry, the first layer is extracted as a gzipped tarball, and devcontainer.json is parsed from it. Cycle detection uses the OCI ref string directly. Includes retry with exponential backoff for transient registry errors. --- pkg/devcontainer/config/extends.go | 5 + pkg/devcontainer/config/extends_oci.go | 176 ++++++++++++++++++ pkg/devcontainer/config/extends_oci_test.go | 189 ++++++++++++++++++++ pkg/devcontainer/config/extends_test.go | 5 +- 4 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 pkg/devcontainer/config/extends_oci.go create mode 100644 pkg/devcontainer/config/extends_oci_test.go diff --git a/pkg/devcontainer/config/extends.go b/pkg/devcontainer/config/extends.go index 46b4a950b..a718fd721 100644 --- a/pkg/devcontainer/config/extends.go +++ b/pkg/devcontainer/config/extends.go @@ -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) diff --git a/pkg/devcontainer/config/extends_oci.go b/pkg/devcontainer/config/extends_oci.go new file mode 100644 index 000000000..c376b59ab --- /dev/null +++ b/pkg/devcontainer/config/extends_oci.go @@ -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 +} diff --git a/pkg/devcontainer/config/extends_oci_test.go b/pkg/devcontainer/config/extends_oci_test.go new file mode 100644 index 000000000..5cfcddccd --- /dev/null +++ b/pkg/devcontainer/config/extends_oci_test.go @@ -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 + }, + } +} diff --git a/pkg/devcontainer/config/extends_test.go b/pkg/devcontainer/config/extends_test.go index 54b20b011..18e89113f 100644 --- a/pkg/devcontainer/config/extends_test.go +++ b/pkg/devcontainer/config/extends_test.go @@ -13,6 +13,7 @@ const ( testNameChild = "child" testImageUbuntu = "ubuntu:20.04" testUserRoot = "root" + testUserVscode = "vscode" testOriginParent = "/tmp/parent.json" testOriginChild = "/tmp/child.json" testFileBase = "base.json" @@ -52,7 +53,7 @@ func TestExtends_BasicScalarOverride(t *testing.T) { if cfg.Image != testImageUbuntu { t.Errorf("expected image 'ubuntu:20.04', got %q", cfg.Image) } - if cfg.RemoteUser != "vscode" { + if cfg.RemoteUser != testUserVscode { t.Errorf("expected remoteUser 'vscode', got %q", cfg.RemoteUser) } if !cfg.Extends.IsEmpty() { @@ -550,7 +551,7 @@ func TestExtends_ArrayMultipleRefs_Scalars(t *testing.T) { if cfg.Image != testImageUbuntu { t.Errorf("expected image from base, got %q", cfg.Image) } - if cfg.RemoteUser != "vscode" { + if cfg.RemoteUser != testUserVscode { t.Errorf("expected remoteUser from middle, got %q", cfg.RemoteUser) } }