From eceb03da3cd3dffdb814428cd704231818bf8681 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Mon, 21 Sep 2026 17:16:32 +0300 Subject: [PATCH 1/2] 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 --- internal/builder/build.go | 18 +- internal/builder/kraftfile.go | 24 +- internal/builder/kraftfile_test.go | 75 +++-- internal/builder/rootfs.go | 443 ++++++++++++++++++++++++---- internal/builder/rootfs_oci_test.go | 438 +++++++++++++++++++++++++++ internal/builder/rootfs_test.go | 103 ++++++- 6 files changed, 984 insertions(+), 117 deletions(-) create mode 100644 internal/builder/rootfs_oci_test.go diff --git a/internal/builder/build.go b/internal/builder/build.go index 6f7f9b67..fd01d9be 100644 --- a/internal/builder/build.go +++ b/internal/builder/build.go @@ -218,7 +218,7 @@ func Build(ctx context.Context, opts BuildOpts) ([]*imagespec.Image, error) { } // Attach rootfs/initrd and use its config if available. - cfg := buildImageConfig(opts) + cfg := applyConfigOverrides(ocispec.ImageConfig{}, opts) if i < len(roots) { rootfsPlatformID := platforms.Format(roots[i].Image.Platform) if rootfsPlatformID != pID { @@ -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..eb5db2b7 100644 --- a/internal/builder/kraftfile.go +++ b/internal/builder/kraftfile.go @@ -8,7 +8,6 @@ package builder import ( "cmp" "fmt" - "path/filepath" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -58,22 +57,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) 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 +76,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..f2fca260 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,28 @@ func TestKraftfileToBuildOptsNoRootfs(t *testing.T) { require.Equal(t, "x86_64", opts.Platform[0].Architecture) require.Equal(t, "fc", opts.Platform[0].OS) } + +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..3e48bca8 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,25 +40,37 @@ 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, Env and Labels on top of +// base, which is the config of the image the rootfs was built from. +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 := slices.Clone(cfg.Env) for _, kv := range opts.Env { - env = append(env, fmt.Sprintf("%s=%s", kv.Key, kv.Value)) + entry := fmt.Sprintf("%s=%s", kv.Key, kv.Value) + i := slices.IndexFunc(env, func(e string) bool { + name, _, _ := strings.Cut(e, "=") + return name == kv.Key + }) + if i < 0 { + env = append(env, entry) + continue + } + env[i] = entry } cfg.Env = env } - cfg.Labels = opts.Labels + if opts.Labels != nil { + cfg.Labels = opts.Labels + } return cfg } @@ -74,21 +87,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 +113,50 @@ 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 +} + func BuildRoms(ctx context.Context, opts BuildOpts) (_ [][]imagespec.File, rerr error) { var romFiles [][]imagespec.File @@ -174,7 +225,8 @@ func BuildRootfs(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rer 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 +271,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) } @@ -228,7 +282,7 @@ func BuildRootfs(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rer // file. The file is opened read-only per platform. // The caller must not delete it. func buildRootfsPackaged(_ context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { - cfg := buildImageConfig(opts) + cfg := applyConfigOverrides(ocispec.ImageConfig{}, opts) var imgs []*imagespec.Image for _, p := range opts.Platform { @@ -254,7 +308,7 @@ func buildRootfsPackaged(_ context.Context, opts BuildOpts) (_ []*imagespec.Imag // buildRootfsFromDirectory archives the source directory into a temporary // rootfs file (CPIO or EroFS, based on opts.Rootfs.Format) for each platform. func buildRootfsDirectory(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { - cfg := buildImageConfig(opts) + cfg := applyConfigOverrides(ocispec.ImageConfig{}, opts) var imgs []*imagespec.Image for _, p := range opts.Platform { @@ -285,7 +339,7 @@ func buildRootfsDirectory(ctx context.Context, opts BuildOpts) (_ []*imagespec.I // buildRootfsTarball opens the source tarball as an fs.FS and packages it // into the requested rootfs format (CPIO or EroFS) for each platform. func buildRootfsTarball(ctx context.Context, opts BuildOpts) (_ []*imagespec.Image, rerr error) { - cfg := buildImageConfig(opts) + cfg := applyConfigOverrides(ocispec.ImageConfig{}, opts) tarFile, err := os.Open(opts.Rootfs.Path) if err != nil { @@ -324,18 +378,218 @@ 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 + } + + 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 { + 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 := applyConfigOverrides(ocispec.ImageConfig{}, 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 := f.Sync(); err != nil { + return nil, fmt.Errorf("could not sync file: %w", 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 := f.Sync(); err != nil { + return nil, fmt.Errorf("could not sync file: %w", 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 +707,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,17 +766,8 @@ 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 } if err := destFS.Sync(); err != nil { @@ -544,6 +777,88 @@ func packageFS(ctx context.Context, format kraftfile.FsType, destFS *os.File, sr return nil } +// padFile pads f up to a multiple of pad bytes. +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) + } + } + + return nil +} + +// buildkitSession returns the session attachables every solve needs. +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 +} + func applyBuildOpts(attrs map[string]string, localDirs map[string]string, sessions *[]session.Attachable, opts BuildOpts) error { if opts.Rootfs.Dockerfile != "" { localDirs["context"] = opts.Rootfs.Path @@ -594,16 +909,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..5a7d67ff --- /dev/null +++ b/internal/builder/rootfs_oci_test.go @@ -0,0 +1,438 @@ +// 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" + "cmp" + "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" + "unikraft.com/x/kraftfile" +) + +// 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) + + config := ocispec.Image{ + Architecture: "amd64", + OS: "linux", + Config: ocispec.ImageConfig{ + Cmd: []string{"/bin/sh"}, + }, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{diffID}, + }, + } + 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: []ocispec.Descriptor{layerDesc}, + } + 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(), "regular-image.tar") + 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) + writeTarEntry(t, tw, "blobs/sha256/"+layerDesc.Digest.Encoded(), layerBlob) + 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) + 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) +} + +// 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) + require.Equal(t, []string{"FROM_IMAGE=1", "SHADOWED=opts"}, + 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, + Format: kraftfile.FsTypeCpio, + 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. +// regularImageRef is a small multi-arch image of plain OCI layers, which is +// what the flatten path needs. +const regularImageRef = "index.docker.io/library/hello-world:latest" + +func TestRootfsOCIRegularImageIntegration(t *testing.T) { + 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: regularImageRef, + 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: regularImageRef, + 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: regularImageRef, + 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: regularImageRef, + 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"}) +} diff --git a/internal/builder/rootfs_test.go b/internal/builder/rootfs_test.go index b433f3fd..e765b23c 100644 --- a/internal/builder/rootfs_test.go +++ b/internal/builder/rootfs_test.go @@ -10,6 +10,7 @@ import ( "context" "errors" "io" + "io/fs" "os" "path/filepath" "testing" @@ -36,7 +37,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 +92,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 +567,81 @@ 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", "SHADOWED=base"}, + Labels: map[string]string{"base": "base", "shared": "base"}, + } + opts := BuildOpts{ + Cmd: []string{"/override"}, + Env: kraftfile.Map{ + {Key: "FOO", Value: "bar"}, + {Key: "SHADOWED", Value: "opt"}, + }, + Labels: map[string]string{"opt": "opt", "shared": "opt"}, + } + + cfg := applyConfigOverrides(base, opts) + require.Equal(t, []string{"/override"}, cfg.Cmd) + require.Equal(t, []string{"PATH=/bin", "SHADOWED=opt", "FOO=bar"}, cfg.Env, + "an entry of the caller must replace the entry of the base, not shadow it") + 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.Equal(t, base.Labels, cfg.Labels, + "the labels of the base must survive when the caller sets none") +} + +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 +688,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 +922,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 { From 302e8b62385f8264533db2c39e845af4b0863b46 Mon Sep 17 00:00:00 2001 From: Cezar Craciunoiu Date: Mon, 21 Sep 2026 17:17:08 +0300 Subject: [PATCH 2/2] test(integration): Mirror OCI images per project Tests that read a regular OCI image pulled hello-world from Docker Hub on every build. The pull is rate limited per account, and other users have no access to a fixed project. A mirrored image now copies the image one time into the organization of the profile, next to the other shared images. The tests use that reference. Signed-off-by: Cezar Craciunoiu --- internal/builder/rootfs_oci_test.go | 27 +++++----- internal/images/images.go | 42 +++++++++++---- internal/integration/image.go | 84 +++++++++++++++++++++++++++-- 3 files changed, 126 insertions(+), 27 deletions(-) diff --git a/internal/builder/rootfs_oci_test.go b/internal/builder/rootfs_oci_test.go index 5a7d67ff..67eaaa14 100644 --- a/internal/builder/rootfs_oci_test.go +++ b/internal/builder/rootfs_oci_test.go @@ -23,6 +23,7 @@ import ( imagespec "unikraft.com/x/image-spec" "unikraft.com/cli/internal/builder/buildfs" + "unikraft.com/cli/internal/integration" "unikraft.com/x/kraftfile" ) @@ -350,16 +351,15 @@ func TestRootfsOCIRegularImageNonRegistry(t *testing.T) { // 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. -// regularImageRef is a small multi-arch image of plain OCI layers, which is -// what the flatten path needs. -const regularImageRef = "index.docker.io/library/hello-world:latest" - func TestRootfsOCIRegularImageIntegration(t *testing.T) { + ctx := rootfsIntegrationContext(t) + ref := integration.HelloWorld.Mirror(t, ctx) + for _, format := range []kraftfile.FsType{kraftfile.FsTypeCpio, kraftfile.FsTypeErofs} { t.Run(string(format), func(t *testing.T) { - imgs := runBuildRootfsIntegration(t, rootfsIntegrationContext(t), BuildOpts{ + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ Rootfs: FSOpts{ - Path: regularImageRef, + Path: ref, Type: kraftfile.SourceTypeOCI, Format: format, }, @@ -383,9 +383,10 @@ func TestRootfsOCIRegularImageIntegration(t *testing.T) { // --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{ + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ Rootfs: FSOpts{ - Path: regularImageRef, + Path: integration.HelloWorld.Mirror(t, ctx), Type: kraftfile.SourceTypeOCI, Format: kraftfile.FsTypeCpio, }, @@ -400,9 +401,10 @@ func TestRootfsOCIRegularImageNoCacheIntegration(t *testing.T) { // 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{ + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ Rootfs: FSOpts{ - Path: regularImageRef, + Path: integration.HelloWorld.Mirror(t, ctx), Type: kraftfile.SourceTypeOCI, Format: kraftfile.FsTypeCpio, }, @@ -421,9 +423,10 @@ func TestRootfsOCIRegularImagePerArchIntegration(t *testing.T) { // 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{ + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ Rootfs: FSOpts{ - Path: regularImageRef, + Path: integration.HelloWorld.Mirror(t, ctx), Type: kraftfile.SourceTypeOCI, Format: kraftfile.FsTypeCpio, }, diff --git a/internal/images/images.go b/internal/images/images.go index e011ae86..a6e075a3 100644 --- a/internal/images/images.go +++ b/internal/images/images.go @@ -9,6 +9,7 @@ import ( "context" "fmt" + "github.com/containerd/containerd/v2/core/remotes" "github.com/containerd/containerd/v2/core/remotes/docker" "github.com/distribution/reference" imagespec "unikraft.com/x/image-spec" @@ -33,6 +34,33 @@ func WithInsecureContext(ctx context.Context, opts ...AccessorOpt) context.Conte } func Accessor(ctx context.Context, opts ...AccessorOpt) (*imagespec.Accessor, error) { + options, err := resolverOptionsFor(ctx, opts...) + if err != nil { + return nil, err + } + + return imagespec.NewAccessor( + imagespec.WithResolver(docker.NewResolver(options)), + imagespec.WithRegistryHosts(options.Hosts), + imagespec.WithRegistryHeaders(options.Headers), + imagespec.WithReferenceParser(ParseNormalizedNamed), + ), nil +} + +// Resolver gives the registry resolver that Accessor uses, for callers that +// fetch or push content themselves. +func Resolver(ctx context.Context, opts ...AccessorOpt) (remotes.Resolver, error) { + options, err := resolverOptionsFor(ctx, opts...) + if err != nil { + return nil, err + } + + return docker.NewResolver(options), nil +} + +// resolverOptionsFor builds the registry options from the profile in ctx. With +// no opts, the insecure options that WithInsecureContext carries are used. +func resolverOptionsFor(ctx context.Context, opts ...AccessorOpt) (docker.ResolverOptions, error) { if len(opts) == 0 { if ctxOpts, ok := ctx.Value(insecureContextKey{}).([]AccessorOpt); ok { opts = ctxOpts @@ -44,20 +72,12 @@ func Accessor(ctx context.Context, opts ...AccessorOpt) (*imagespec.Accessor, er opt(&o) } - cfg := config.FromContextOrDefault(ctx) - profile, err := cfg.CurrentProfile() + profile, err := config.FromContextOrDefault(ctx).CurrentProfile() if err != nil { - return nil, err + return docker.ResolverOptions{}, err } - options := resolverOptions(profile, o.insecureRegistries, o.allInsecure) - resolver := docker.NewResolver(options) - return imagespec.NewAccessor( - imagespec.WithResolver(resolver), - imagespec.WithRegistryHosts(options.Hosts), - imagespec.WithRegistryHeaders(options.Headers), - imagespec.WithReferenceParser(ParseNormalizedNamed), - ), nil + return resolverOptions(profile, o.insecureRegistries, o.allInsecure), nil } // AccessorOpt is a functional option for configuring an Accessor. diff --git a/internal/integration/image.go b/internal/integration/image.go index 2d13f3c1..5114cfb1 100644 --- a/internal/integration/image.go +++ b/internal/integration/image.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/require" + "github.com/moby/buildkit/util/contentutil" imagespec "unikraft.com/x/image-spec" "unikraft.com/cli/internal/config" @@ -83,6 +84,81 @@ func (i *Image) Build(t *testing.T, env *TestEnv, ref string, opts ...CmdOption) return nil } +// HelloWorld is a regular OCI image, with plain layers and no unikraft +// components. +var HelloWorld = &MirroredImage{ + Name: "hello-world", + Source: "index.docker.io/library/hello-world:latest", +} + +// MirroredImage is a public image that tests copy into the organization of the +// profile, so that each user runs against their own project. Mirror copies it +// one time, and the cleanup deletes it when the test binary ends. +type MirroredImage struct { + // Name is the image name, without the organization and the tag. + Name string + // Source is the full reference of the image to copy. + Source string + + once sync.Once + ref string + err error +} + +// Mirror copies the image one time and gives the full reference. Later calls +// give the reference of the first copy. +func (m *MirroredImage) Mirror(t *testing.T, ctx context.Context) string { + t.Helper() + m.once.Do(func() { + m.ref, m.err = m.mirror(ctx) + }) + require.NoError(t, m.err) + return m.ref +} + +// mirror copies every manifest and blob of the source image, so that a +// multi-platform image keeps all of its platforms. +func (m *MirroredImage) mirror(ctx context.Context) (string, error) { + cfg := config.FromContextOrDefault(ctx) + profile, err := cfg.CurrentProfile() + if err != nil { + return "", err + } + if profile.Organization == "" { + return "", fmt.Errorf("profile %q has no organization to mirror %s into", profile.Name, m.Name) + } + named, err := images.ParseNormalizedNamed(profile.Organization + "/" + m.Name + ":" + sharedImageTag) + if err != nil { + return "", fmt.Errorf("parsing mirror reference of %s: %w", m.Name, err) + } + ref := named.String() + + resolver, err := images.Resolver(ctx) + if err != nil { + return "", err + } + + _, desc, err := resolver.Resolve(ctx, m.Source) + if err != nil { + return "", fmt.Errorf("resolving %s: %w", m.Source, err) + } + fetcher, err := resolver.Fetcher(ctx, m.Source) + if err != nil { + return "", fmt.Errorf("fetching %s: %w", m.Source, err) + } + pusher, err := resolver.Pusher(ctx, ref) + if err != nil { + return "", fmt.Errorf("pushing to %s: %w", ref, err) + } + + if err := contentutil.CopyChain(ctx, contentutil.FromPusher(pusher), contentutil.FromFetcher(fetcher), desc); err != nil { + return "", fmt.Errorf("mirroring %s to %s: %w", m.Source, ref, err) + } + + registerImageConfig(cfg, ref) + return ref, nil +} + // SharedImage is an image that tests use. Build makes the image one time, // and Cleanup deletes it when the test binary ends. type SharedImage struct { @@ -128,17 +204,17 @@ func (s *SharedImage) build(t *testing.T, env *TestEnv) (string, error) { if err := s.Image.Build(t, env, ref, WithNoPartition(), WithoutCancel()); err != nil { return "", err } - registerSharedImage(env, ref) + registerImageConfig(env.Config.Config, ref) return ref, nil } -// registerSharedImage records ref for deletion after the tests end. -func registerSharedImage(env *TestEnv, ref string) { +// registerImageConfig records ref for deletion after the tests end. +func registerImageConfig(cfg *config.Config, ref string) { sharedImageMu.Lock() defer sharedImageMu.Unlock() if sharedImageConfig == nil { - sharedImageConfig = env.Config.Config + sharedImageConfig = cfg } sharedImageRefs = append(sharedImageRefs, ref) }