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"}) +}