Skip to content

feat(builder): Add rootfs implementation for reading OCI images - #413

Open
craciunoiuc wants to merge 1 commit into
prod-stagingfrom
craciunoiuc/handle-oci-type
Open

feat(builder): Add rootfs implementation for reading OCI images#413
craciunoiuc wants to merge 1 commit into
prod-stagingfrom
craciunoiuc/handle-oci-type

Conversation

@craciunoiuc

@craciunoiuc craciunoiuc commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Tested work and it seemed fine. Pasting again uses:
Case 1:

rootfs:
  format: erofs
  type: oci
  source: index.unikraft.io/test/check-pr:latest

Case 2:

rootfs:
  format: erofs
  type: oci
  source: index.docker.io/hello-world:latest

You can use these prefixes: oci://, oci-layout://, oci-archive:// taken from image-spec:

case imagespec.URISchemeOCI, imagespec.URISchemeOCILayout, imagespec.URISchemeOCIArchive:

We decided to use only the oci type variable.

Also needs another bump to the gomod.

Closes: TOOL-1073

@craciunoiuc

Copy link
Copy Markdown
Contributor Author

let me know if these single use functions you want to inline

I found them significant enough to keep separate for now

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support in the builder to treat OCI image references (registry, OCI layout, OCI archive) as rootfs sources, including logic to unpack regular OCI layers into a rootfs and to use a Unikraft-specific initrd component when present.

Changes:

  • Detect OCI rootfs sources via URI schemes and route BuildRootfs to a new OCI implementation.
  • Implement buildRootfsOCI to load OCI images per requested platform, flatten layers into a directory, and package into CPIO/EROFS.
  • Update kraftfile-to-buildopts translation and expand unit/integration-style tests for OCI sources.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
internal/builder/rootfs.go Adds OCI source detection + buildRootfsOCI (load, platform match, layer flatten, repack).
internal/builder/rootfs_test.go Adds DetectSourceType tests for OCI schemes and integration-style rootfs OCI tests.
internal/builder/rootfs_oci_test.go Adds helpers to generate local OCI archives used by tests.
internal/builder/kraftfile.go Avoids filepath-joining when rootfs/ROM source is an OCI reference or explicitly typed OCI.
internal/builder/kraftfile_test.go Adds coverage for OCI paths/types in kraftfile-to-buildopts conversion.
go.mod Pulls in new (indirect) deps required by containerd/v2 usage.
go.sum Updates sums for newly introduced transitive dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/builder/rootfs_oci_test.go Outdated
Comment thread internal/builder/rootfs.go Outdated
Comment thread internal/builder/kraftfile.go Outdated
Comment thread internal/builder/rootfs.go Outdated
Comment thread internal/builder/rootfs.go Outdated
@craciunoiuc
craciunoiuc force-pushed the craciunoiuc/handle-oci-type branch from 90f96a2 to ae59715 Compare July 30, 2026 14:34
Comment thread internal/builder/rootfs.go Outdated
@craciunoiuc
craciunoiuc marked this pull request as draft July 30, 2026 14:59
@craciunoiuc
craciunoiuc force-pushed the craciunoiuc/handle-oci-type branch 3 times, most recently from c3ae0df to fe4bd3f Compare July 30, 2026 18:59
Comment thread internal/builder/rootfs.go Outdated
Comment thread internal/builder/rootfs.go Outdated
return nil, fmt.Errorf("could not open flattened rootfs image as filesystem: %w", err)
}

f, err := os.CreateTemp("", "unikraft-rootfs-*."+string(opts.Rootfs.Format))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to start to think if we want to start moving these image build operations into the local directory

Tmpfs gets really crowded when we chuck so many image artifacts into it

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

internal/builder/rootfs.go:57

  • cfg.Labels = opts.Labels overwrites any labels coming from the base image config even when opts.Labels is nil/unset, which loses inherited labels for OCI-rootfs builds (and any other path that uses applyConfigOverrides). Consider only overriding labels when the caller actually provided them (e.g., if opts.Labels != nil { ... }), or merge maps so explicitly-provided keys override while preserving the rest.
func applyConfigOverrides(base ocispec.ImageConfig, opts BuildOpts) ocispec.ImageConfig {
	cfg := base
	if opts.Cmd != nil {
		cfg.Cmd = opts.Cmd
	}
	if opts.Env != nil {
		env := make([]string, 0, len(opts.Env)+len(cfg.Env))
		for _, kv := range opts.Env {
			env = append(env, fmt.Sprintf("%s=%s", kv.Key, kv.Value))
		}
		cfg.Env = append(env, cfg.Env...)
	}
	cfg.Labels = opts.Labels
	return cfg
}

internal/builder/rootfs_oci_test.go:90

  • In OCI image config, RootFS.DiffIDs are expected to be the digests of the uncompressed layer tar streams (diffIDs), while the manifest layer descriptors typically use the digest of the (often compressed) blob. Here diffIDs are derived from desc.Digest (from tarGzipLayer), which makes the fixture OCI config non-spec-compliant and could break consumers that validate diffIDs. Consider computing diffIDs from the uncompressed tar bytes (or streaming hasher before gzip) while keeping desc.Digest for the compressed blob.
	diffIDs := make([]digest.Digest, 0, len(layerDescs))
	for _, desc := range layerDescs {
		diffIDs = append(diffIDs, desc.Digest)
	}

	config := ocispec.Image{
		Platform: ocispec.Platform{
			Architecture: "amd64",
			OS:           "linux",
		},
		Config: ocispec.ImageConfig{
			Cmd: []string{"/bin/sh"},
		},
		RootFS: ocispec.RootFS{
			Type:    "layers",
			DiffIDs: diffIDs,
		},
	}

Comment thread internal/builder/rootfs.go
Uses buildkit to download and use OCI images.
Works with both regular images, and Unikraft images.
Unikraft images have their rootfs already packaged so we fast forward.

Signed-off-by: Cezar Craciunoiu <cezar@unikraft.io>
@craciunoiuc
craciunoiuc force-pushed the craciunoiuc/handle-oci-type branch from e7d40aa to 5efd1e1 Compare July 31, 2026 09:32
@craciunoiuc
craciunoiuc marked this pull request as ready for review July 31, 2026 15:21
@jedevc
jedevc self-requested a review July 31, 2026 15:29

// errSourceNotExist is returned by DetectSourceType when the source path is not
// present on disk.
var errSourceNotExist = errors.New("rootfs path does not exist")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.ErrFileNotFound or whatever it's called 🤔

Comment on lines +114 to +124
// detectPackagedFormat reports the rootfs format of an already-packaged file.
func detectPackagedFormat(path string) (kraftfile.FsType, error) {
switch {
case gocpio.IsValidPath(path):
return kraftfile.FsTypeCpio, nil
case goerofs.IsValidPath(path):
return kraftfile.FsTypeErofs, nil
default:
return "", fmt.Errorf("could not detect rootfs format of %q", path)
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use this in DetectSourceType too.

opts.Rootfs.Format = kf.Rootfs.Format
opts.Rootfs.Type = kf.Rootfs.Source.Type
opts.Rootfs.Dockerfile = kf.Rootfs.Source.Dockerfile
if opts.Rootfs.Dockerfile != "" && opts.Rootfs.Type == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to call resolveSource here? Why do we defer it?

The opts are invalid, we should make them valid here.

}

if root != "" {
fsOpts.Path = filepath.Join(root, fsOpts.Path)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels a bit icky. Instead, the caller should resolve these, it should determine these, instead of magically combining them here.

Comment thread internal/builder/build.go
type BuildOpts struct {
Rootfs FSOpts
Roms []FSOpts
RootPath string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a comment explaining what this is for.

ref += "@" + src.Descriptor.Digest.String()
}

imagePlatform := getPlatforms([]ocispec.Platform{src.Image.Platform})[0].Platform

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split this to getPlatform, then this reads a lot better.

Comment on lines +148 to +153
// nopWriteCloser adapts a writer we do not own into an io.WriteCloser.
type nopWriteCloser struct {
io.Writer
}

func (nopWriteCloser) Close() error { return nil }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this an x/io helper.

Comment thread internal/cmd/volumes.go
Comment on lines -738 to -741
sourceType, err := builder.DetectSourceType(c.Source)
if err != nil {
return fmt.Errorf("detecting source type for %q: %w", c.Source, err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👀 this feels weird to remove.

I really liked the previous property of opts passed in that once we passed them in they were ready to go. Not anything left to be resolved, or defaults.

Comment on lines +221 to +223
func digestHex(dgst digest.Digest) string {
return strings.TrimPrefix(string(dgst), "sha256:")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace with dgst.Encoded().

Comment on lines +990 to +1013
// runBuildRootfsOCI calls BuildRootfs against a local oci-archive source. Only
// usable for unikraft images, whose initrd component is read directly and so
// needs no BuildKit.
func runBuildRootfsOCI(t *testing.T, opts BuildOpts) []*imagespec.Image {
t.Helper()

imgs, err := BuildRootfs(rootfsOCIUnitContext(t), opts)
require.NoError(t, err)
t.Cleanup(func() {
for _, img := range imgs {
_ = img.Close()
}
})
return imgs
}

// runBuildRootfsOCIBuildkit calls BuildRootfs with BuildKit wired up, which
// flattening the layers of a regular OCI image requires. Since those are read
// from a registry, this needs network access too.
func runBuildRootfsOCIBuildkit(t *testing.T, opts BuildOpts) []*imagespec.Image {
t.Helper()

return runBuildRootfsIntegration(t, rootfsIntegrationContext(t), opts)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these helpers 😓

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants