From 871b81cb50beac0e12344392d27ddbbbfe84907f Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Thu, 9 Jul 2026 12:25:32 +0300 Subject: [PATCH] feat(builder): Add rootfs implementation for reading OCI images 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 --- go.mod | 1 + go.sum | 2 + internal/builder/build.go | 16 +- internal/builder/kraftfile.go | 27 +- internal/builder/kraftfile_test.go | 104 +++++-- internal/builder/rootfs.go | 454 ++++++++++++++++++++++++---- internal/builder/rootfs_oci_test.go | 216 +++++++++++++ internal/builder/rootfs_test.go | 333 +++++++++++++++++++- 8 files changed, 1038 insertions(+), 115 deletions(-) create mode 100644 internal/builder/rootfs_oci_test.go diff --git a/go.mod b/go.mod index 27309789..cf77c9c1 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,7 @@ require ( unikraft.com/x/guesstermwidth v0.0.0-20260813113709-544c471e0bc9 unikraft.com/x/iata v0.0.0-20260713183529-fd34645687a0 unikraft.com/x/image-spec v0.0.0-20260813113709-544c471e0bc9 + unikraft.com/x/io v0.0.0-20260819084004-6de0d3f1ed2c unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de unikraft.com/x/kingkong v0.0.0-20260824095305-c69507b68d29 unikraft.com/x/kraftfile v0.0.0-20260522114044-e2da24d09716 diff --git a/go.sum b/go.sum index 983625ae..37036500 100644 --- a/go.sum +++ b/go.sum @@ -507,6 +507,8 @@ unikraft.com/x/iata v0.0.0-20260713183529-fd34645687a0 h1:wpRYHKQbrcJM4xSb8MyumZ unikraft.com/x/iata v0.0.0-20260713183529-fd34645687a0/go.mod h1:M9v3TZKETfctR1aqFxM6UqYM+3DJ+nHKtqrxGb8X8oc= unikraft.com/x/image-spec v0.0.0-20260813113709-544c471e0bc9 h1:S0Er9pKNkS4SdifkcXUAQ+P+ndMIBRMheyLZP8f6g9E= unikraft.com/x/image-spec v0.0.0-20260813113709-544c471e0bc9/go.mod h1:7HwHLKMC6Ican+0HeMFVN8qI3mSyf0M64iEZj++FoPs= +unikraft.com/x/io v0.0.0-20260819084004-6de0d3f1ed2c h1:K/HL18OZWL/FeBC3/TcuOaQePMTzmYw4CzaKHxjf+Uk= +unikraft.com/x/io v0.0.0-20260819084004-6de0d3f1ed2c/go.mod h1:k4MCTNUTf7yB9LhOpH4Vj0RecXirtVE3gZLUw5jOFQo= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de h1:cm4FnPvnahRIK0derbI+T4ds1LsD5CFeyyAvIqcOCek= unikraft.com/x/joinerrgroup v0.0.0-20260304162956-523940cab1de/go.mod h1:XND1VvLxwqKFGrmdwUTWps4WEpMm7HTHPQg9HWQtrxg= unikraft.com/x/kingkong v0.0.0-20260824095305-c69507b68d29 h1:C87AsE6sNHtSwM6b+F/8lAOotIDSu3vJ8oUva/xRC9g= diff --git a/internal/builder/build.go b/internal/builder/build.go index 6f7f9b67..7210ca68 100644 --- a/internal/builder/build.go +++ b/internal/builder/build.go @@ -231,19 +231,9 @@ func Build(ctx context.Context, opts BuildOpts) ([]*imagespec.Image, error) { imgOpts = append(imgOpts, imagespec.WithInitrd(roots[i].Initrd)) roots[i].Initrd = nil // The rootfs build may have produced a richer config (e.g. from - // a Dockerfile). Use it as the base and layer our overrides on top. - cfg = roots[i].Image.Config - if opts.Cmd != nil { - cfg.Cmd = opts.Cmd - } - if opts.Env != nil { - env := make([]string, 0, len(opts.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 + // a Dockerfile or an OCI image). Use it as the base and layer our + // overrides on top. + cfg = applyConfigOverrides(roots[i].Image.Config, opts) } imgOpts = append(imgOpts, imagespec.WithImageConfig(cfg)) diff --git a/internal/builder/kraftfile.go b/internal/builder/kraftfile.go index 7f498dc8..67b4edea 100644 --- a/internal/builder/kraftfile.go +++ b/internal/builder/kraftfile.go @@ -6,9 +6,7 @@ package builder import ( - "cmp" "fmt" - "path/filepath" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -58,22 +56,17 @@ func KraftfileToBuildOpts(dir string, kf *kraftfile.Kraftfile) (BuildOpts, error if rom.Source == nil || rom.Source.Path == "" { return BuildOpts{}, fmt.Errorf("rom entry is missing a source path") } - romPath := filepath.Join(dir, rom.Source.Path) - romFormat := cmp.Or(rom.Format, kraftfile.FsTypeErofs) + romFormat := defaultRomFormat(rom.Format, rom.Source.Type) romOpt := FSOpts{ - Path: romPath, + Path: rom.Source.Path, Format: romFormat, Type: rom.Source.Type, // Pad the file to page-size alignment. This is required by the platform // which rejects ROM files that are not page-aligned. Pad: 4096, } - if romOpt.Type == "" { - typ, err := DetectSourceType(romPath) - if err != nil { - return BuildOpts{}, fmt.Errorf("detecting rom type for %q: %w", romPath, err) - } - romOpt.Type = typ + if err := resolveSource(dir, &romOpt); err != nil { + return BuildOpts{}, fmt.Errorf("resolving rom source %q: %w", rom.Source.Path, err) } opts.Roms = append(opts.Roms, romOpt) } @@ -82,18 +75,12 @@ func KraftfileToBuildOpts(dir string, kf *kraftfile.Kraftfile) (BuildOpts, error if kf.Rootfs.Source == nil || kf.Rootfs.Source.Path == "" { return BuildOpts{}, fmt.Errorf("rootfs entry is missing a source path") } - opts.Rootfs.Path = filepath.Join(dir, kf.Rootfs.Source.Path) + opts.Rootfs.Path = kf.Rootfs.Source.Path 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 == "" { - opts.Rootfs.Type = kraftfile.SourceTypeDockerfile - } else if opts.Rootfs.Type == "" { - typ, err := DetectSourceType(opts.Rootfs.Path) - if err != nil { - return BuildOpts{}, fmt.Errorf("detecting rootfs type for %q: %w", opts.Rootfs.Path, err) - } - opts.Rootfs.Type = typ + if err := resolveSource(dir, &opts.Rootfs); err != nil { + return BuildOpts{}, fmt.Errorf("resolving rootfs source %q: %w", kf.Rootfs.Source.Path, err) } } diff --git a/internal/builder/kraftfile_test.go b/internal/builder/kraftfile_test.go index 6cee4770..7f869d52 100644 --- a/internal/builder/kraftfile_test.go +++ b/internal/builder/kraftfile_test.go @@ -6,6 +6,9 @@ package builder import ( + "io/fs" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" @@ -48,7 +51,7 @@ func TestKraftfileToBuildOpts(t *testing.T) { require.Equal(t, map[string]string{"label": "value"}, opts.Labels) require.Equal(t, "unikraft.io/unikraft.org/base", opts.Runtime) require.Equal(t, kraftfile.FsTypeErofs, opts.Rootfs.Format) - require.Equal(t, rootfsDir+"/Dockerfile", opts.Rootfs.Path) + require.Equal(t, filepath.Join(rootfsDir, "Dockerfile"), opts.Rootfs.Path) require.Equal(t, kraftfile.SourceTypeDockerfile, opts.Rootfs.Type) require.Len(t, opts.Platform, 1) require.Equal(t, "x86_64", opts.Platform[0].Architecture) @@ -60,43 +63,50 @@ func TestKraftfileToBuildOpts(t *testing.T) { }, opts.Platform[0].OSFeatures) } -func TestKraftfileToBuildOptsRootfsSourceError(t *testing.T) { +// TestKraftfileToBuildOptsResolvesSources asserts that every source leaves here +// resolved against the kraftfile directory and typed. +func TestKraftfileToBuildOptsResolvesSources(t *testing.T) { rootfsDir := t.TempDir() - rootfsPath := "rootfs.tar" + romPath := filepath.Join(rootfsDir, "romdir") + require.NoError(t, os.Mkdir(romPath, 0o755)) runtime := kraftfile.Runtime("unikraft.io/unikraft.org/base") kf := &kraftfile.Kraftfile{ Runtime: &runtime, Rootfs: &kraftfile.FS{ - Format: kraftfile.FsTypeCpio, + Format: kraftfile.FsTypeErofs, Source: &kraftfile.FSSource{ - Path: rootfsPath, + Path: "Dockerfile", }, }, + Roms: []kraftfile.FS{ + {Source: &kraftfile.FSSource{Path: "romdir"}}, + }, } - _, err := KraftfileToBuildOpts(rootfsDir, kf) - require.Error(t, err) + opts, err := KraftfileToBuildOpts(rootfsDir, kf) + require.NoError(t, err) + require.Equal(t, filepath.Join(rootfsDir, "Dockerfile"), opts.Rootfs.Path) + require.Equal(t, kraftfile.SourceTypeDockerfile, opts.Rootfs.Type) + require.Len(t, opts.Roms, 1) + require.Equal(t, romPath, opts.Roms[0].Path) + require.Equal(t, kraftfile.SourceTypeDirectory, opts.Roms[0].Type) } -func TestKraftfileToBuildOptsRootfsPathJoined(t *testing.T) { - rootfsDir := t.TempDir() - +// TestKraftfileToBuildOptsMissingSource asserts the fail-fast that resolving +// here buys: a bad path is reported before anything connects to BuildKit. +func TestKraftfileToBuildOptsMissingSource(t *testing.T) { runtime := kraftfile.Runtime("unikraft.io/unikraft.org/base") kf := &kraftfile.Kraftfile{ Runtime: &runtime, Rootfs: &kraftfile.FS{ - Format: kraftfile.FsTypeErofs, - Source: &kraftfile.FSSource{ - Path: "Dockerfile", - }, + Source: &kraftfile.FSSource{Path: "rootfs.tar"}, }, } - opts, err := KraftfileToBuildOpts(rootfsDir, kf) - require.NoError(t, err) - require.Equal(t, rootfsDir+"/Dockerfile", opts.Rootfs.Path, - "rootfs path must be joined with the kraftfile directory") + _, err := KraftfileToBuildOpts(t.TempDir(), kf) + require.ErrorIs(t, err, fs.ErrNotExist) + require.ErrorContains(t, err, "resolving rootfs source") } func TestKraftfileToBuildOptsDockerfileWithType(t *testing.T) { @@ -117,7 +127,7 @@ func TestKraftfileToBuildOptsDockerfileWithType(t *testing.T) { opts, err := KraftfileToBuildOpts(rootfsDir, kf) require.NoError(t, err) - require.Equal(t, rootfsDir+"/context", opts.Rootfs.Path) + require.Equal(t, filepath.Join(rootfsDir, "context"), opts.Rootfs.Path) require.Equal(t, "MyDockerfile", opts.Rootfs.Dockerfile) require.Equal(t, kraftfile.SourceTypeDockerfile, opts.Rootfs.Type) require.Equal(t, kraftfile.FsTypeErofs, opts.Rootfs.Format) @@ -140,7 +150,7 @@ func TestKraftfileToBuildOptsDockerfileWithoutType(t *testing.T) { opts, err := KraftfileToBuildOpts(rootfsDir, kf) require.NoError(t, err) - require.Equal(t, rootfsDir+"/context", opts.Rootfs.Path) + require.Equal(t, filepath.Join(rootfsDir, "context"), opts.Rootfs.Path) require.Equal(t, "MyDockerfile", opts.Rootfs.Dockerfile) require.Equal(t, kraftfile.SourceTypeDockerfile, opts.Rootfs.Type, "type must be inferred as dockerfile when dockerfile field is set") @@ -172,3 +182,57 @@ func TestKraftfileToBuildOptsNoRootfs(t *testing.T) { require.Equal(t, "x86_64", opts.Platform[0].Architecture) require.Equal(t, "fc", opts.Platform[0].OS) } + +// TestKraftfileToBuildOptsRomOCIKeepsFormat verifies that a rom keeps the erofs +// default, except for an OCI source, which dictates its own format. +func TestKraftfileToBuildOptsRomOCIKeepsFormat(t *testing.T) { + rootfsDir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(rootfsDir, "rom.bin"), []byte("rom"), 0o644)) + + runtime := kraftfile.Runtime("unikraft.io/unikraft.org/base") + kf := &kraftfile.Kraftfile{ + Runtime: &runtime, + Roms: []kraftfile.FS{ + {Source: &kraftfile.FSSource{ + Path: "index.docker.io/hello-world:latest", + Type: kraftfile.SourceTypeOCI, + }}, + {Source: &kraftfile.FSSource{ + Path: "rom.bin", + Type: kraftfile.SourceTypeTarball, + }}, + }, + } + + opts, err := KraftfileToBuildOpts(rootfsDir, kf) + require.NoError(t, err) + require.Len(t, opts.Roms, 2) + require.Empty(t, opts.Roms[0].Format, + "an OCI rom must keep its own format rather than defaulting to erofs") + require.Equal(t, kraftfile.FsTypeErofs, opts.Roms[1].Format) +} + +func TestKraftfileToBuildOptsRootfsOCIType(t *testing.T) { + rootfsDir := t.TempDir() + + runtime := kraftfile.Runtime("unikraft.io/unikraft.org/base") + kf := &kraftfile.Kraftfile{ + Runtime: &runtime, + Rootfs: &kraftfile.FS{ + Format: kraftfile.FsTypeErofs, + Source: &kraftfile.FSSource{ + Path: "index.docker.io/hello-world:latest", + Type: kraftfile.SourceTypeOCI, + }, + }, + Targets: []kraftfile.Target{ + {Arch: "x86_64", Plat: "fc"}, + }, + } + + opts, err := KraftfileToBuildOpts(rootfsDir, kf) + require.NoError(t, err) + require.Equal(t, "index.docker.io/hello-world:latest", opts.Rootfs.Path, + "OCI rootfs reference must not be joined with the kraftfile directory") + require.Equal(t, kraftfile.SourceTypeOCI, opts.Rootfs.Type) +} diff --git a/internal/builder/rootfs.go b/internal/builder/rootfs.go index 532b4944..2c95b923 100644 --- a/internal/builder/rootfs.go +++ b/internal/builder/rootfs.go @@ -22,6 +22,7 @@ import ( "github.com/containerd/platforms" dockerconfig "github.com/docker/cli/cli/config" "github.com/moby/buildkit/client" + "github.com/moby/buildkit/client/llb" "github.com/moby/buildkit/exporter/containerimage/exptypes" gateway "github.com/moby/buildkit/frontend/gateway/client" "github.com/moby/buildkit/identity" @@ -39,28 +40,36 @@ import ( "unikraft.com/cli/internal/buildkit" "unikraft.com/cli/internal/config" "unikraft.com/cli/internal/images" + ukio "unikraft.com/x/io" "unikraft.com/x/kraftfile" "unikraft.com/x/log" ) -// buildImageConfig constructs a minimal OCI image config from build options. -// Used when a BuildKit solve is not performed. -func buildImageConfig(opts BuildOpts) ocispec.ImageConfig { - var cfg ocispec.ImageConfig +// applyConfigOverrides layers the build options' Cmd and Env on top of base, +// which is the config of the image the rootfs was built from. Env is prepended +// so the caller's values take precedence. Labels replace the base's entirely. +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)) + 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 = env + cfg.Env = append(env, cfg.Env...) } cfg.Labels = opts.Labels return cfg } +// buildImageConfig constructs a minimal OCI image config from build options. +// Used when there is no source image config to build on top of. +func buildImageConfig(opts BuildOpts) ocispec.ImageConfig { + return applyConfigOverrides(ocispec.ImageConfig{}, opts) +} + // DetectSourceType inspects path and returns the detected RootfsType. func DetectSourceType(path string) (kraftfile.SourceType, error) { if path == "" { @@ -74,21 +83,15 @@ func DetectSourceType(path string) (kraftfile.SourceType, error) { fi, err := os.Stat(path) if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("rootfs path does not exist") - } - return "", fmt.Errorf("checking rootfs source %q: %w", path, err) + return "", fmt.Errorf("checking rootfs source: %w", err) } switch { case fi.IsDir(): return kraftfile.SourceTypeDirectory, nil case fi.Mode().IsRegular(), fi.Mode()&os.ModeSymlink != 0: - if gocpio.IsValidPath(path) { - return kraftfile.SourceTypeCpio, nil - } - if goerofs.IsValidPath(path) { - return kraftfile.SourceTypeErofs, nil + if format, err := detectPackagedFormat(path); err == nil { + return kraftfile.SourceType(format), nil } if f, err := os.Open(path); err == nil { defer f.Close() @@ -106,6 +109,59 @@ func DetectSourceType(path string) (kraftfile.SourceType, error) { } } +// 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) + } +} + +// resolveSource resolves the source of fsOpts against root and fills in the +// source type when it was not requested explicitly. +func resolveSource(root string, fsOpts *FSOpts) error { + if fsOpts.Type == kraftfile.SourceTypeOCI { + if fsOpts.Dockerfile != "" { + return fmt.Errorf("a dockerfile cannot be set when the source type is %q", kraftfile.SourceTypeOCI) + } + return nil + } + + if root != "" { + fsOpts.Path = filepath.Join(root, fsOpts.Path) + } + + if fsOpts.Dockerfile != "" { + if fsOpts.Type != "" && fsOpts.Type != kraftfile.SourceTypeDockerfile { + return fmt.Errorf("source type must be %q when a dockerfile is set, got %q", kraftfile.SourceTypeDockerfile, fsOpts.Type) + } + fsOpts.Type = kraftfile.SourceTypeDockerfile + } + + if fsOpts.Type == "" { + typ, err := DetectSourceType(fsOpts.Path) + if err != nil { + return err + } + fsOpts.Type = typ + } + + return nil +} + +// defaultRomFormat fills in the ROM default format. An OCI source carries its +// own format, so leave it unset and let the source dictate it. +func defaultRomFormat(format kraftfile.FsType, typ kraftfile.SourceType) kraftfile.FsType { + if format != "" || typ == kraftfile.SourceTypeOCI { + return format + } + return kraftfile.FsTypeErofs +} + func BuildRoms(ctx context.Context, opts BuildOpts) (_ [][]imagespec.File, rerr error) { var romFiles [][]imagespec.File @@ -128,7 +184,7 @@ func BuildRoms(ctx context.Context, opts BuildOpts) (_ [][]imagespec.File, rerr Rootfs: FSOpts{ Path: rom.Path, Type: rom.Type, - Format: cmp.Or(rom.Format, kraftfile.FsTypeErofs), + Format: defaultRomFormat(rom.Format, rom.Type), Pad: rom.Pad, }, // propagate BuildKit options from the parent build @@ -172,9 +228,12 @@ func BuildRootfs(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rer return nil, fmt.Errorf("at least one platform must be specified") } + // Sources that are already packaged carry their own format, so leave it + // unset and let the branch that knows the source fill it in. if opts.Rootfs.Format == "" && opts.Rootfs.Type != kraftfile.SourceTypeCpio && - opts.Rootfs.Type != kraftfile.SourceTypeErofs { + opts.Rootfs.Type != kraftfile.SourceTypeErofs && + opts.Rootfs.Type != kraftfile.SourceTypeOCI { opts.Rootfs.Format = DefaultRootfsFormat(opts.Platform) } @@ -219,6 +278,8 @@ func BuildRootfs(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rer } } return buildRootfsDockerfile(ctx, opts) + case kraftfile.SourceTypeOCI: + return buildRootfsOCI(ctx, opts) default: return nil, fmt.Errorf("unsupported rootfs type %q", opts.Rootfs.Type) } @@ -324,18 +385,222 @@ func buildRootfsTarball(ctx context.Context, opts BuildOpts) (_ []*imagespec.Ima return imgs, nil } -func buildRootfsDockerfile(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { - dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) - - profile, err := config.G(ctx).CurrentProfile() +// buildRootfsOCI pulls an OCI image and builds a rootfs from it for each +// requested platform. Two kinds of images are supported: Regular OCI and +// Unikraft images +func buildRootfsOCI(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { + access, err := images.Accessor(ctx) if err != nil { return nil, err } - session := []session.Attachable{ - authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{ - AuthConfigProvider: images.LoadBuildkitAuthConfig(dockerConfig, profile), - }), + uri, err := imagespec.ParseURIDefault(opts.Rootfs.Path) + if err != nil { + return nil, fmt.Errorf("parsing rootfs image reference %q: %w", opts.Rootfs.Path, err) + } + + imagePlatforms := getPlatforms(opts.Platform) + wanted := make([]ocispec.Platform, 0, 2*len(opts.Platform)) + for i, p := range opts.Platform { + wanted = append(wanted, p, imagePlatforms[i].Platform) + } + + matcher := ignoringOSFeatures(platforms.Any(wanted...)) + loaded, err := access.LoadAll(ctx, uri, matcher) + if err != nil { + return nil, fmt.Errorf("pulling rootfs image %q: %w", opts.Rootfs.Path, err) + } + defer func() { + for _, img := range loaded { + _ = img.Close() + } + }() + + byPlatform := make(map[string]*imagespec.Image, len(loaded)) + for _, img := range loaded { + if img.Image == nil { + continue + } + byPlatform[platforms.Format(platforms.Normalize(img.Image.Platform))] = img + } + + // Keyed by image identity: two platforms share a flattened filesystem only + // when they resolve to the same loaded image. + flattened := make(map[*imagespec.Image]fs.FS, len(loaded)) + + var imgs []*imagespec.Image + for i, p := range opts.Platform { + src := byPlatform[platforms.Format(platforms.Normalize(p))] + if src == nil { + src = byPlatform[platforms.Format(imagePlatforms[i].Platform)] + } + if src == nil { + // A single-platform image carries no manifest to match against, so + // accept it only when there is no other platform to confuse it with. + if len(loaded) == 1 && len(opts.Platform) == 1 { + src = loaded[0] + } else { + return nil, fmt.Errorf("rootfs image %q does not contain platform %q", opts.Rootfs.Path, platforms.Format(p)) + } + } + + cfg := buildImageConfig(opts) + if src.Image != nil { + cfg = applyConfigOverrides(src.Image.Config, opts) + } + + if src.Initrd != nil { + f, err := os.CreateTemp("", "unikraft-rootfs-*") + if err != nil { + return nil, fmt.Errorf("could not create temporary file: %w", err) + } + defer func() { + if rerr != nil && f != nil { + f.Close() + os.Remove(f.Name()) + } + }() + + rc, _, err := src.Initrd.Open(ctx) + if err != nil { + return nil, fmt.Errorf("opening rootfs layer: %w", err) + } + if _, err := io.Copy(f, rc); err != nil { + rc.Close() + return nil, fmt.Errorf("reading rootfs layer: %w", err) + } + if err := rc.Close(); err != nil { + return nil, fmt.Errorf("closing rootfs layer: %w", err) + } + if err := syncFile(f); err != nil { + return nil, err + } + + format, err := detectPackagedFormat(f.Name()) + if err != nil { + return nil, fmt.Errorf("inspecting initrd of rootfs image %q: %w", opts.Rootfs.Path, err) + } + if opts.Rootfs.Format != "" && opts.Rootfs.Format != format { + return nil, fmt.Errorf("unsupported rootfs format mismatch: source is %s but requested format is %s", format, opts.Rootfs.Format) + } + + if err := padFile(f, opts.Rootfs.Pad); err != nil { + return nil, err + } + if err := syncFile(f); err != nil { + return nil, err + } + + imgs = append(imgs, imagespec.NewImage( + imagespec.WithImageConfig(cfg), + imagespec.WithPlatform(p), + imagespec.WithInitrd(imagespec.NewTempOSFile(f)), + )) + continue + } + + format := cmp.Or(opts.Rootfs.Format, DefaultRootfsFormat(opts.Platform)) + + srcFS, ok := flattened[src] + if !ok { + layers, err := os.CreateTemp("", "unikraft-buildkit-*.tar") + if err != nil { + return nil, fmt.Errorf("could not create temporary file: %w", err) + } + defer func() { + layers.Close() + os.Remove(layers.Name()) + }() + + if err := flattenImageLayers(ctx, opts, src, uri, layers); err != nil { + return nil, err + } + + srcFS, err = buildfs.TarballFS(layers) + if err != nil { + return nil, fmt.Errorf("could not open flattened rootfs image as filesystem: %w", err) + } + flattened[src] = srcFS + } + + f, err := os.CreateTemp("", "unikraft-rootfs-*."+string(format)) + if err != nil { + return nil, fmt.Errorf("could not create temporary file: %w", err) + } + defer func() { + if rerr != nil && f != nil { + f.Close() + os.Remove(f.Name()) + } + }() + + if err := packageFS(ctx, format, f, srcFS, opts.Rootfs); err != nil { + return nil, err + } + + imgs = append(imgs, imagespec.NewImage( + imagespec.WithImageConfig(cfg), + imagespec.WithPlatform(p), + imagespec.WithInitrd(imagespec.NewTempOSFile(f)), + )) + } + + return imgs, nil +} + +// flattenImageLayers writes the flattened filesystem of a regular OCI image to +// dst as an uncompressed tarball, using BuildKit to do the flattening. +func flattenImageLayers(ctx context.Context, opts BuildOpts, src *imagespec.Image, uri *imagespec.URI, dst *os.File) error { + if uri.Scheme != imagespec.URISchemeOCI { + return fmt.Errorf("rootfs image %q must be a registry reference, %q is not supported", uri.Path, uri.Scheme) + } + + if src.Image == nil { + return fmt.Errorf("rootfs image %q has no config to take a platform from", uri.Path) + } + + ref := uri.Path + if src.Descriptor.Digest != "" && !strings.Contains(ref, "@") { + ref += "@" + src.Descriptor.Digest.String() + } + + imagePlatform := getPlatform(src.Image.Platform).Platform + + imageOpts := []llb.ImageOption{llb.Platform(imagePlatform)} + constraints := []llb.ConstraintsOpt{llb.Platform(imagePlatform)} + if opts.NoCache { + imageOpts = append(imageOpts, llb.ResolveModeForcePull) + constraints = append(constraints, llb.IgnoreCache) + } + + def, err := llb.Image(ref, imageOpts...).Marshal(ctx, constraints...) + if err != nil { + return fmt.Errorf("marshalling rootfs image source: %w", err) + } + + session, err := buildkitSession(ctx) + if err != nil { + return err + } + + err = solveToTar(ctx, dst, client.SolveOpt{Session: session}, + func(ctx context.Context, c gateway.Client) (*gateway.Result, error) { + return c.Solve(ctx, gateway.SolveRequest{ + Definition: def.ToPB(), + Evaluate: true, + }) + }) + if err != nil { + return fmt.Errorf("flattening rootfs image %q: %w", uri.Path, err) + } + + return nil +} + +func buildRootfsDockerfile(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { + session, err := buildkitSession(ctx) + if err != nil { + return nil, err } attrs := map[string]string{} @@ -453,20 +718,8 @@ func buildRootfsDockerfile(ctx context.Context, opts BuildOpts) (_ []*imagespec. return nil, err } - if opts.Cmd != nil { - config.Config.Cmd = opts.Cmd - } - if opts.Env != nil { - env := make([]string, 0, len(opts.Env)) - for _, kv := range opts.Env { - env = append(env, fmt.Sprintf("%s=%s", kv.Key, kv.Value)) - } - config.Config.Env = append(env, config.Config.Env...) - } - config.Config.Labels = opts.Labels - imgs = append(imgs, imagespec.NewImage( - imagespec.WithImageConfig(config.Config), + imagespec.WithImageConfig(applyConfigOverrides(config.Config, opts)), imagespec.WithPlatform(p), imagespec.WithInitrd(imagespec.NewTempOSFile(f)), )) @@ -524,22 +777,101 @@ func packageFS(ctx context.Context, format kraftfile.FsType, destFS *os.File, sr return fmt.Errorf("unknown filesystem type %q", format) } - if opts.Pad > 0 { - pos, err := destFS.Seek(0, io.SeekEnd) - if err != nil { - return fmt.Errorf("could not seek to end of file: %w", err) - } - if rem := pos % opts.Pad; rem != 0 { - pad := make([]byte, opts.Pad-rem) - if _, err := destFS.Write(pad); err != nil { - return fmt.Errorf("could not pad file to page alignment: %w", err) - } + if err := padFile(destFS, opts.Pad); err != nil { + return err + } + + return syncFile(destFS) +} + +// padFile pads f up to a multiple of pad bytes. A pad of zero does nothing. +func padFile(f *os.File, pad int64) error { + if pad <= 0 { + return nil + } + + pos, err := f.Seek(0, io.SeekEnd) + if err != nil { + return fmt.Errorf("could not seek to end of file: %w", err) + } + if rem := pos % pad; rem != 0 { + padding := make([]byte, pad-rem) + if _, err := f.Write(padding); err != nil { + return fmt.Errorf("could not pad file to page alignment: %w", err) } } - if err := destFS.Sync(); err != nil { + return nil +} + +// syncFile flushes f to disk. +func syncFile(f *os.File) error { + if err := f.Sync(); err != nil { return fmt.Errorf("could not sync file: %w", err) } + return nil +} + +// buildkitSession returns the session attachables every solve needs, which is +// the registry auth wired up from both the docker config and the current +// profile. +func buildkitSession(ctx context.Context) ([]session.Attachable, error) { + profile, err := config.G(ctx).CurrentProfile() + if err != nil { + return nil, err + } + dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) + + return []session.Attachable{ + authprovider.NewDockerAuthProvider(authprovider.DockerAuthProviderConfig{ + AuthConfigProvider: images.LoadBuildkitAuthConfig(dockerConfig, profile), + }), + }, nil +} + +// solveToTar runs build against BuildKit, exporting an uncompressed tarball of +// the result to dst, and waits for the progress writer to drain. +func solveToTar(ctx context.Context, dst *os.File, solveOpt client.SolveOpt, build gateway.BuildFunc) error { + solveOpt.Ref = identity.NewID() + solveOpt.Exports = []client.ExportEntry{{ + Type: client.ExporterTar, + Output: func(map[string]string) (io.WriteCloser, error) { + return ukio.NopWriteCloser(dst), nil + }, + }} + + c, cleanup, err := buildkit.ConnectToBuildkit(ctx) + if err != nil { + return err + } + if cleanup != nil { + defer cleanup() + } + + pw, err := progresswriter.NewPrinter(context.WithoutCancel(ctx), os.Stderr, "auto") + if err != nil { + return err + } + + if _, err := c.Build(ctx, solveOpt, "buildctl", build, pw.Status()); err != nil { + return err + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-pw.Done(): + } + if pw.Err() != nil { + return pw.Err() + } + + if err := dst.Sync(); err != nil { + return fmt.Errorf("could not sync tarball: %w", err) + } + if _, err := dst.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("could not rewind tarball: %w", err) + } return nil } @@ -594,16 +926,22 @@ func applyBuildOpts(attrs map[string]string, localDirs map[string]string, sessio return nil } +// getPlatform maps a unikraft target platform onto the linux platform BuildKit +// builds for. +func getPlatform(p ocispec.Platform) exptypes.Platform { + p.OS = "linux" + p.OSFeatures = nil + p.OSVersion = "" + p = platforms.Normalize(p) + return exptypes.Platform{ + ID: platforms.Format(p), + Platform: p, + } +} + func getPlatforms(ps []ocispec.Platform) (exp []exptypes.Platform) { - for _, platform := range ps { - platform.OS = "linux" - platform.OSFeatures = nil - platform.OSVersion = "" - platform = platforms.Normalize(platform) - exp = append(exp, exptypes.Platform{ - ID: platforms.Format(platform), - Platform: platform, - }) + for _, p := range ps { + exp = append(exp, getPlatform(p)) } return exp } diff --git a/internal/builder/rootfs_oci_test.go b/internal/builder/rootfs_oci_test.go new file mode 100644 index 00000000..b4d4c7d7 --- /dev/null +++ b/internal/builder/rootfs_oci_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package builder + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" + imagespec "unikraft.com/x/image-spec" + + "unikraft.com/cli/internal/builder/buildfs" +) + +// writeUnikraftOCIArchive packages srcDir into a CPIO initrd, wraps it in a +// unikraft-style OCI image (with a dedicated initrd component), and saves the +// result as an OCI archive tarball. Extra image options, e.g. a config to +// inherit, are appended. It returns the path to the tarball. +func writeUnikraftOCIArchive(t *testing.T, srcDir string, extra ...imagespec.NewImageOpt) string { + t.Helper() + + cpioPath := filepath.Join(t.TempDir(), "initrd.cpio") + f, err := os.Create(cpioPath) + require.NoError(t, err) + defer f.Close() + + ctx := context.Background() + require.NoError(t, buildfs.CreateCPIO(ctx, f, os.DirFS(srcDir))) + require.NoError(t, f.Sync()) + + cpioFile, err := os.Open(cpioPath) + require.NoError(t, err) + t.Cleanup(func() { cpioFile.Close() }) + + img := imagespec.NewImage(append([]imagespec.NewImageOpt{ + imagespec.WithPlatform(ocispec.Platform{OS: "fc", Architecture: "x86_64"}), + imagespec.WithInitrd(imagespec.NewOSFile(cpioFile)), + }, extra...)...) + + archivePath := filepath.Join(t.TempDir(), "unikraft-image.tar") + require.NoError(t, imagespec.SaveTarball(ctx, archivePath, img)) + return archivePath +} + +// writeRegularOCIArchive builds a regular OCI image (plain OCI layers, no +// unikraft components) from srcDir and saves it as an OCI archive tarball. +// It returns the path to the tarball. +func writeRegularOCIArchive(t *testing.T, srcDir string) string { + t.Helper() + + layerDesc, layerBlob, diffID := tarGzipLayer(t, srcDir) + return writeOCIArchive(t, "regular-image.tar", + []ocispec.Descriptor{layerDesc}, [][]byte{layerBlob}, []digest.Digest{diffID}) +} + +// writeOCIArchive wraps the given layers in a regular OCI image and saves it as +// an OCI archive tarball named name. diffIDs are the digests of the uncompressed +// layers, which is what the config records, as opposed to the descriptors' +// digests of the compressed blobs. It returns the path to the tarball. +func writeOCIArchive(t *testing.T, name string, layerDescs []ocispec.Descriptor, layerBlobs [][]byte, diffIDs []digest.Digest) string { + t.Helper() + require.Len(t, diffIDs, len(layerDescs)) + + config := ocispec.Image{ + Architecture: "amd64", + OS: "linux", + Config: ocispec.ImageConfig{ + Cmd: []string{"/bin/sh"}, + }, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: diffIDs, + }, + } + configJSON, err := json.Marshal(config) + require.NoError(t, err) + configDesc, configBlob := newDescriptor("application/vnd.oci.image.config.v1+json", configJSON) + + manifest := ocispec.Manifest{ + SchemaVersion: 2, + MediaType: ocispec.MediaTypeImageManifest, + Config: configDesc, + Layers: layerDescs, + } + manifestJSON, err := json.Marshal(manifest) + require.NoError(t, err) + manifestDesc, manifestBlob := newDescriptor(ocispec.MediaTypeImageManifest, manifestJSON) + + // A regular OCI image advertises a real platform, not a unikraft one. The + // builder matches the requested unikraft target against its normalised + // linux equivalent. + index := ocispec.Index{ + SchemaVersion: 2, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{{ + MediaType: ocispec.MediaTypeImageManifest, + Digest: manifestDesc.Digest, + Size: manifestDesc.Size, + Platform: &ocispec.Platform{ + Architecture: "amd64", + OS: "linux", + }, + }}, + } + indexJSON, err := json.Marshal(index) + require.NoError(t, err) + + archivePath := filepath.Join(t.TempDir(), name) + out, err := os.Create(archivePath) + require.NoError(t, err) + defer out.Close() + + tw := tar.NewWriter(out) + defer tw.Close() + + writeTarEntry(t, tw, "blobs/sha256/"+configDesc.Digest.Encoded(), configBlob) + writeTarEntry(t, tw, "blobs/sha256/"+manifestDesc.Digest.Encoded(), manifestBlob) + for i, desc := range layerDescs { + writeTarEntry(t, tw, "blobs/sha256/"+desc.Digest.Encoded(), layerBlobs[i]) + } + writeTarEntry(t, tw, "index.json", indexJSON) + writeTarEntry(t, tw, "oci-layout", []byte(`{"imageLayoutVersion":"1.0.0"}`)) + + return archivePath +} + +// newDescriptor computes the sha256 digest and size of data and returns a +// descriptor with the given media type plus the raw bytes. +func newDescriptor(mediaType string, data []byte) (ocispec.Descriptor, []byte) { + return ocispec.Descriptor{ + MediaType: mediaType, + Digest: digest.FromBytes(data), + Size: int64(len(data)), + }, data +} + +// tarGzipLayer walks srcDir and returns a gzip-compressed tar layer descriptor, +// its raw blob bytes, and the diffID, which is the digest of the tar stream +// before compression. +func tarGzipLayer(t *testing.T, srcDir string) (ocispec.Descriptor, []byte, digest.Digest) { + t.Helper() + + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + // Hash the uncompressed stream as it is written, so that the diffID does not + // require keeping a second copy of the layer around. + diffIDer := digest.SHA256.Digester() + tw := tar.NewWriter(io.MultiWriter(gw, diffIDer.Hash())) + + err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(rel) + if info.IsDir() { + hdr.Name += "/" + } + if err := tw.WriteHeader(hdr); err != nil { + return err + } + if !info.Mode().IsRegular() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + _, err = tw.Write(data) + return err + }) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gw.Close()) + + blob := buf.Bytes() + desc := ocispec.Descriptor{ + MediaType: ocispec.MediaTypeImageLayerGzip, + Digest: digest.FromBytes(blob), + Size: int64(len(blob)), + } + return desc, blob, diffIDer.Digest() +} + +func writeTarEntry(t *testing.T, tw *tar.Writer, name string, data []byte) { + t.Helper() + require.NoError(t, tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o644, + Size: int64(len(data)), + })) + _, err := io.Copy(tw, bytes.NewReader(data)) + require.NoError(t, err) +} diff --git a/internal/builder/rootfs_test.go b/internal/builder/rootfs_test.go index b433f3fd..066de4e7 100644 --- a/internal/builder/rootfs_test.go +++ b/internal/builder/rootfs_test.go @@ -7,9 +7,11 @@ package builder import ( "archive/tar" + "cmp" "context" "errors" "io" + "io/fs" "os" "path/filepath" "testing" @@ -36,7 +38,7 @@ func TestDetectSourceTypeEmpty(t *testing.T) { func TestDetectSourceTypeNonexistent(t *testing.T) { _, err := DetectSourceType(filepath.Join(t.TempDir(), "nonexistent")) - require.ErrorContains(t, err, "rootfs path does not exist") + require.ErrorIs(t, err, fs.ErrNotExist) } func TestDetectSourceTypeDockerfile(t *testing.T) { @@ -91,6 +93,11 @@ func TestDetectSourceTypeTarball(t *testing.T) { require.Equal(t, kraftfile.SourceTypeTarball, typ) } +func TestDetectSourceTypeNotAnImageRef(t *testing.T) { + _, err := DetectSourceType("index.docker.io/hello-world:latest") + require.Error(t, err) +} + func TestDetectSourceTypeUnknown(t *testing.T) { p := filepath.Join(t.TempDir(), "random.bin") require.NoError(t, os.WriteFile(p, []byte("not an archive"), 0o644)) @@ -561,6 +568,76 @@ func TestRomPerPlatform(t *testing.T) { require.NotSame(t, romFiles[0][0], romFiles[0][1]) } +func TestApplyConfigOverrides(t *testing.T) { + base := ocispec.ImageConfig{ + Cmd: []string{"/base"}, + Env: []string{"PATH=/bin"}, + Labels: map[string]string{"base": "base", "shared": "base"}, + } + opts := BuildOpts{ + Cmd: []string{"/override"}, + Env: kraftfile.Map{{Key: "FOO", Value: "bar"}}, + Labels: map[string]string{"opt": "opt", "shared": "opt"}, + } + + cfg := applyConfigOverrides(base, opts) + require.Equal(t, []string{"/override"}, cfg.Cmd) + require.Equal(t, []string{"FOO=bar", "PATH=/bin"}, cfg.Env) + require.Equal(t, opts.Labels, cfg.Labels, + "labels must replace the base's, not merge with them") +} + +func TestApplyConfigOverridesEmpty(t *testing.T) { + base := ocispec.ImageConfig{ + Cmd: []string{"/base"}, + Env: []string{"PATH=/bin"}, + Labels: map[string]string{"base": "base"}, + } + + cfg := applyConfigOverrides(base, BuildOpts{}) + require.Equal(t, base.Cmd, cfg.Cmd) + require.Equal(t, base.Env, cfg.Env) + require.Nil(t, cfg.Labels) +} + +func TestResolveSourceRelativeToRoot(t *testing.T) { + dir := writeTestDirectory(t) + root, base := filepath.Split(dir) + + fsOpts := FSOpts{Path: base} + require.NoError(t, resolveSource(root, &fsOpts)) + require.Equal(t, dir, fsOpts.Path) + require.Equal(t, kraftfile.SourceTypeDirectory, fsOpts.Type) +} + +func TestResolveSourceDockerfileType(t *testing.T) { + fsOpts := FSOpts{Path: "context", Dockerfile: "MyDockerfile"} + require.NoError(t, resolveSource("/root", &fsOpts)) + require.Equal(t, "/root/context", fsOpts.Path) + require.Equal(t, kraftfile.SourceTypeDockerfile, fsOpts.Type) +} + +func TestResolveSourceDockerfileConflictingType(t *testing.T) { + fsOpts := FSOpts{Path: "context", Dockerfile: "MyDockerfile", Type: kraftfile.SourceTypeTarball} + require.ErrorContains(t, resolveSource("/root", &fsOpts), "source type must be") +} + +func TestResolveSourceOCIWithDockerfile(t *testing.T) { + fsOpts := FSOpts{Path: "index.unikraft.io/test/img:latest", Type: kraftfile.SourceTypeOCI, Dockerfile: "MyDockerfile"} + require.ErrorContains(t, resolveSource("/root", &fsOpts), "dockerfile cannot be set") +} + +func TestResolveSourceOCIKeepsReference(t *testing.T) { + fsOpts := FSOpts{Path: "index.unikraft.io/test/img:latest", Type: kraftfile.SourceTypeOCI} + require.NoError(t, resolveSource("/root", &fsOpts)) + require.Equal(t, "index.unikraft.io/test/img:latest", fsOpts.Path) +} + +func TestResolveSourceMissingPath(t *testing.T) { + fsOpts := FSOpts{Path: "rootfs.tar"} + require.ErrorIs(t, resolveSource(t.TempDir(), &fsOpts), fs.ErrNotExist) +} + func TestRootfsUnsupportedType(t *testing.T) { ctx := t.Context() ctx = log.WithLogger(ctx, log.New(t.Output(), log.TextType, log.InfoLevel)) @@ -607,6 +684,22 @@ func TestRootfsErofsSourceCpioFormatMismatch(t *testing.T) { require.ErrorContains(t, err, "rootfs format mismatch") } +// builderTestContext returns a context with the minimal config the builder +// needs, which for an OCI source is a profile for the accessor's resolver +// options. +func builderTestContext(t *testing.T) context.Context { + t.Helper() + ctx := t.Context() + ctx = log.WithLogger(ctx, log.New(t.Output(), log.TextType, log.InfoLevel)) + + return config.WithConfig(ctx, &config.Config{ + DefaultProfile: "default", + Profiles: map[string]config.Profile{ + "default": {Name: "default", Type: config.ProfileTypeLocal}, + }, + }) +} + func rootfsIntegrationContext(t *testing.T) context.Context { t.Helper() integration.SkipUnlessIntegration(t) @@ -825,10 +918,8 @@ func writeTestTarballFile(t *testing.T) string { // runBuildRootfs calls BuildRootfs and registers cleanup for the returned images. func runBuildRootfs(t *testing.T, opts BuildOpts) []*imagespec.Image { t.Helper() - ctx := t.Context() - ctx = log.WithLogger(ctx, log.New(t.Output(), log.TextType, log.InfoLevel)) - imgs, err := BuildRootfs(ctx, opts) + imgs, err := BuildRootfs(builderTestContext(t), opts) require.NoError(t, err) t.Cleanup(func() { for _, img := range imgs { @@ -837,3 +928,237 @@ func runBuildRootfs(t *testing.T, opts BuildOpts) []*imagespec.Image { }) return imgs } + +// TestRootfsOCIUnikraftImage reads a unikraft-style OCI image that carries a +// dedicated initrd component and verifies that the initrd is passed through +// untouched, without being repackaged. +func TestRootfsOCIUnikraftImage(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeUnikraftOCIArchive(t, srcDir) + + for _, format := range []kraftfile.FsType{"", kraftfile.FsTypeCpio} { + t.Run(cmp.Or(string(format), "unset"), func(t *testing.T) { + imgs := runBuildRootfs(t, BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + Format: format, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.Len(t, imgs, 1) + + files := readCpioInitrd(t, imgs[0]) + require.Contains(t, files, "./hello.txt") + require.Equal(t, "hello\n", files["./hello.txt"]) + require.Contains(t, files, "./subdir/nested.txt") + require.Equal(t, "nested\n", files["./subdir/nested.txt"]) + }) + } +} + +// TestRootfsOCIUnikraftImageFormatMismatch asserts that a format the initrd +// cannot satisfy is reported rather than silently ignored. +func TestRootfsOCIUnikraftImageFormatMismatch(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeUnikraftOCIArchive(t, srcDir) + + _, err := BuildRootfs(builderTestContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + Format: kraftfile.FsTypeErofs, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.ErrorContains(t, err, "rootfs format mismatch") +} + +// TestRootfsOCIUnikraftImageConfig asserts that the source image's config +// survives, and that the build options still override it. +func TestRootfsOCIUnikraftImageConfig(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeUnikraftOCIArchive(t, srcDir, imagespec.WithImageConfig(ocispec.ImageConfig{ + Cmd: []string{"/from-image"}, + Env: []string{"FROM_IMAGE=1", "SHADOWED=image"}, + })) + + t.Run("inherited", func(t *testing.T) { + imgs := runBuildRootfs(t, BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.Len(t, imgs, 1) + require.Equal(t, []string{"/from-image"}, imgs[0].Image.Config.Cmd) + require.Contains(t, imgs[0].Image.Config.Env, "FROM_IMAGE=1") + }) + + t.Run("overridden", func(t *testing.T) { + imgs := runBuildRootfs(t, BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + Cmd: []string{"/from-opts"}, + Env: kraftfile.Map{{Key: "SHADOWED", Value: "opts"}}, + }) + require.Len(t, imgs, 1) + require.Equal(t, []string{"/from-opts"}, imgs[0].Image.Config.Cmd) + // Ours is prepended, so it wins over the image's on a duplicate key, + // while the image's other values are kept. + require.Equal(t, []string{"SHADOWED=opts", "FROM_IMAGE=1", "SHADOWED=image"}, + imgs[0].Image.Config.Env) + }) +} + +// TestRomOCIUnikraftImagePadded covers the path BuildRoms takes: a ROM must be +// page-aligned or the platform rejects it. +func TestRomOCIUnikraftImagePadded(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeUnikraftOCIArchive(t, srcDir) + + roms, err := BuildRoms(builderTestContext(t), BuildOpts{ + Roms: []FSOpts{{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + Pad: 4096, + }}, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.NoError(t, err) + require.Len(t, roms, 1) + require.Len(t, roms[0], 1) + t.Cleanup(func() { _ = roms[0][0].Cleanup() }) + + _, size, err := roms[0][0].Open(t.Context()) + require.NoError(t, err) + require.NotZero(t, size) + require.Zero(t, size%4096, "rom must be padded to page alignment") +} + +// TestRootfsOCISinglePlatformImageMultiplePlatforms verifies that a +// single-platform image is not silently reused for every requested platform. +func TestRootfsOCISinglePlatformImageMultiplePlatforms(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeUnikraftOCIArchive(t, srcDir) + + _, err := BuildRootfs(builderTestContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + }, + Platform: []ocispec.Platform{ + {OS: "fc", Architecture: "x86_64"}, + {OS: "fc", Architecture: "arm64"}, + }, + }) + require.ErrorContains(t, err, "does not contain platform") +} + +func TestRootfsOCIRegularImageNonRegistry(t *testing.T) { + srcDir := writeTestDirectory(t) + archivePath := writeRegularOCIArchive(t, srcDir) + + _, err := BuildRootfs(builderTestContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "oci-archive://" + archivePath, + Type: kraftfile.SourceTypeOCI, + Format: kraftfile.FsTypeCpio, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.ErrorContains(t, err, "must be a registry reference") +} + +// TestRootfsOCIRegularImageIntegration reads a regular OCI image (plain layers, +// no unikraft components) from a registry and verifies that BuildKit flattens +// the layers and that the result is re-packaged into the requested rootfs +// format. +func TestRootfsOCIRegularImageIntegration(t *testing.T) { + const ref = "index.docker.io/library/hello-world:latest" + + for _, format := range []kraftfile.FsType{kraftfile.FsTypeCpio, kraftfile.FsTypeErofs} { + t.Run(string(format), func(t *testing.T) { + imgs := runBuildRootfsIntegration(t, rootfsIntegrationContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: ref, + Type: kraftfile.SourceTypeOCI, + Format: format, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + require.Len(t, imgs, 1) + + switch format { + case kraftfile.FsTypeCpio: + files := readCpioInitrd(t, imgs[0]) + require.Contains(t, files, "./hello") + case kraftfile.FsTypeErofs: + files := readErofsInitrd(t, imgs[0]) + require.Contains(t, files, "hello") + } + }) + } +} + +// TestRootfsOCIRegularImageNoCacheIntegration guards the options that carry +// --no-cache into a raw LLB solve, which does not see the frontend attribute the +// Dockerfile path uses. +func TestRootfsOCIRegularImageNoCacheIntegration(t *testing.T) { + imgs := runBuildRootfsIntegration(t, rootfsIntegrationContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "index.docker.io/library/hello-world:latest", + Type: kraftfile.SourceTypeOCI, + Format: kraftfile.FsTypeCpio, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + NoCache: true, + }) + require.Len(t, imgs, 1) + require.Contains(t, readCpioInitrd(t, imgs[0]), "./hello") +} + +// TestRootfsOCIRegularImagePerArchIntegration covers two platforms that resolve +// to different manifests of one multi-arch image: each must be flattened on its +// own rather than sharing the first one's filesystem. +func TestRootfsOCIRegularImagePerArchIntegration(t *testing.T) { + imgs := runBuildRootfsIntegration(t, rootfsIntegrationContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "index.docker.io/library/hello-world:latest", + Type: kraftfile.SourceTypeOCI, + Format: kraftfile.FsTypeCpio, + }, + Platform: []ocispec.Platform{ + {OS: "fc", Architecture: "x86_64"}, + {OS: "fc", Architecture: "arm64"}, + }, + }) + require.Len(t, imgs, 2) + require.NotEqual(t, readCpioInitrd(t, imgs[0]), readCpioInitrd(t, imgs[1]), + "each architecture must get its own flattened filesystem") + assertPlatforms(t, imgs, []string{"fc/x86_64", "fc/arm64"}) +} + +// TestRootfsOCIRegularImageSharedFlattenIntegration covers two unikraft +// platforms that normalise onto the same linux platform: they share one source +// image, so the flatten is done once and reused rather than solved per platform. +func TestRootfsOCIRegularImageSharedFlattenIntegration(t *testing.T) { + imgs := runBuildRootfsIntegration(t, rootfsIntegrationContext(t), BuildOpts{ + Rootfs: FSOpts{ + Path: "index.docker.io/library/hello-world:latest", + Type: kraftfile.SourceTypeOCI, + Format: kraftfile.FsTypeCpio, + }, + Platform: []ocispec.Platform{ + {OS: "fc", Architecture: "x86_64"}, + {OS: "qemu", Architecture: "x86_64"}, + }, + }) + require.Len(t, imgs, 2) + require.Equal(t, readCpioInitrd(t, imgs[0]), readCpioInitrd(t, imgs[1])) + assertPlatforms(t, imgs, []string{"fc/x86_64", "qemu/x86_64"}) +}