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..67eaaa14 --- /dev/null +++ b/internal/builder/rootfs_oci_test.go @@ -0,0 +1,441 @@ +// 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/cli/internal/integration" + "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. +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, ctx, 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) { + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ + Rootfs: FSOpts{ + Path: integration.HelloWorld.Mirror(t, ctx), + 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) { + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ + Rootfs: FSOpts{ + Path: integration.HelloWorld.Mirror(t, ctx), + 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) { + ctx := rootfsIntegrationContext(t) + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ + Rootfs: FSOpts{ + Path: integration.HelloWorld.Mirror(t, ctx), + 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 { 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) }