Merge pull request #4026 from deitch/increment-tar-output

add support for input-tar
This commit is contained in:
Avi Deitcher 2024-04-19 17:08:03 +03:00 committed by GitHub
commit dd1ae909d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 369 additions and 48 deletions

View File

@ -55,6 +55,7 @@ func buildCmd() *cobra.Command {
outputTypes = moby.OutputTypes()
noSbom bool
sbomOutputFilename string
inputTar string
sbomCurrentTime bool
dryRun bool
)
@ -94,7 +95,7 @@ The generated image can be in one of multiple formats which can be run on variou
if len(buildFormats) > 1 {
for _, o := range buildFormats {
if moby.Streamable(o) {
return fmt.Errorf("Format type %s must be the only format specified", o)
return fmt.Errorf("format type %s must be the only format specified", o)
}
}
}
@ -109,23 +110,27 @@ The generated image can be in one of multiple formats which can be run on variou
} else {
err := moby.ValidateFormats(buildFormats, cacheDir.String())
if err != nil {
return fmt.Errorf("Error parsing formats: %v", err)
return fmt.Errorf("error parsing formats: %v", err)
}
}
if inputTar != "" && pull {
return fmt.Errorf("cannot use --input-tar and --pull together")
}
var outfile *os.File
if outputFile != "" {
if len(buildFormats) > 1 {
return fmt.Errorf("The -output option can only be specified when generating a single output format")
return fmt.Errorf("the -output option can only be specified when generating a single output format")
}
if name != "" {
return fmt.Errorf("The -output option cannot be specified with -name")
return fmt.Errorf("the -output option cannot be specified with -name")
}
if dir != "" {
return fmt.Errorf("The -output option cannot be specified with -dir")
return fmt.Errorf("the -output option cannot be specified with -dir")
}
if !moby.Streamable(buildFormats[0]) {
return fmt.Errorf("The -output option cannot be specified for build type %s as it cannot be streamed", buildFormats[0])
return fmt.Errorf("the -output option cannot be specified for build type %s as it cannot be streamed", buildFormats[0])
}
if outputFile == "-" {
outfile = os.Stdout
@ -133,7 +138,7 @@ The generated image can be in one of multiple formats which can be run on variou
var err error
outfile, err = os.Create(outputFile)
if err != nil {
log.Fatalf("Cannot open output file: %v", err)
log.Fatalf("cannot open output file: %v", err)
}
defer outfile.Close()
}
@ -141,7 +146,7 @@ The generated image can be in one of multiple formats which can be run on variou
size, err := getDiskSizeMB(sizeString)
if err != nil {
log.Fatalf("Unable to parse disk size: %v", err)
log.Fatalf("unable to parse disk size: %v", err)
}
var (
@ -154,25 +159,25 @@ The generated image can be in one of multiple formats which can be run on variou
var err error
config, err = io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("Cannot read stdin: %v", err)
return fmt.Errorf("cannot read stdin: %v", err)
}
} else if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
buffer := new(bytes.Buffer)
response, err := http.Get(arg)
if err != nil {
return fmt.Errorf("Cannot fetch remote yaml file: %v", err)
return fmt.Errorf("cannot fetch remote yaml file: %v", err)
}
defer response.Body.Close()
_, err = io.Copy(buffer, response.Body)
if err != nil {
return fmt.Errorf("Error reading http body: %v", err)
return fmt.Errorf("error reading http body: %v", err)
}
config = buffer.Bytes()
} else {
var err error
config, err = os.ReadFile(conf)
if err != nil {
return fmt.Errorf("Cannot open config file: %v", err)
return fmt.Errorf("cannot open config file: %v", err)
}
// templates are only supported for local files
templatesSupported = true
@ -183,18 +188,18 @@ The generated image can be in one of multiple formats which can be run on variou
}
c, err := moby.NewConfig(config, pkgFinder)
if err != nil {
return fmt.Errorf("Invalid config: %v", err)
return fmt.Errorf("invalid config: %v", err)
}
m, err = moby.AppendConfig(m, c)
if err != nil {
return fmt.Errorf("Cannot append config files: %v", err)
return fmt.Errorf("cannot append config files: %v", err)
}
}
if dryRun {
yml, err := yaml.Marshal(m)
if err != nil {
return fmt.Errorf("Error generating YAML: %v", err)
return fmt.Errorf("error generating YAML: %v", err)
}
fmt.Println(string(yml))
return nil
@ -206,7 +211,7 @@ The generated image can be in one of multiple formats which can be run on variou
w = outfile
} else {
if tf, err = os.CreateTemp("", ""); err != nil {
log.Fatalf("Error creating tempfile: %v", err)
log.Fatalf("error creating tempfile: %v", err)
}
defer os.Remove(tf.Name())
w = tf
@ -225,7 +230,7 @@ The generated image can be in one of multiple formats which can be run on variou
return fmt.Errorf("error creating sbom generator: %v", err)
}
}
err = moby.Build(m, w, moby.BuildOpts{Pull: pull, BuilderType: tp, DecompressKernel: decompressKernel, CacheDir: cacheDir.String(), DockerCache: docker, Arch: arch, SbomGenerator: sbomGenerator})
err = moby.Build(m, w, moby.BuildOpts{Pull: pull, BuilderType: tp, DecompressKernel: decompressKernel, CacheDir: cacheDir.String(), DockerCache: docker, Arch: arch, SbomGenerator: sbomGenerator, InputTar: inputTar})
if err != nil {
return fmt.Errorf("%v", err)
}
@ -233,13 +238,13 @@ The generated image can be in one of multiple formats which can be run on variou
if outfile == nil {
image := tf.Name()
if err := tf.Close(); err != nil {
return fmt.Errorf("Error closing tempfile: %v", err)
return fmt.Errorf("error closing tempfile: %v", err)
}
log.Infof("Create outputs:")
err = moby.Formats(filepath.Join(dir, name), image, buildFormats, size, arch, cacheDir.String())
if err != nil {
return fmt.Errorf("Error writing outputs: %v", err)
return fmt.Errorf("error writing outputs: %v", err)
}
}
return nil
@ -255,6 +260,7 @@ The generated image can be in one of multiple formats which can be run on variou
cmd.Flags().BoolVar(&decompressKernel, "decompress-kernel", false, "Decompress the Linux kernel (default false)")
cmd.Flags().StringVar(&arch, "arch", runtime.GOARCH, "target architecture for which to build")
cmd.Flags().VarP(&buildFormats, "format", "f", "Formats to create [ "+strings.Join(outputTypes, " ")+" ]")
cmd.Flags().StringVar(&inputTar, "input-tar", "", "path to tar from previous linuxkit build to use as input; if provided, will take files from images from this tar, using OCI images only to replace or update files. Always copies to a temporary working directory to avoid overwriting. Only works if input-tar file has the linuxkit.yaml used to build it in the exact same location. Incompatible with --pull")
cacheDir = flagOverEnvVarOverDefaultString{def: defaultLinuxkitCache(), envVar: envVarCacheDir}
cmd.Flags().Var(&cacheDir, "cache", fmt.Sprintf("Directory for caching and finding cached image, overrides env var %s", envVarCacheDir))
cmd.Flags().BoolVar(&noSbom, "no-sbom", false, "suppress consolidation of sboms on input container images to a single sbom and saving in the output filesystem")

View File

@ -83,7 +83,7 @@ func OutputTypes() []string {
return ts
}
func outputImage(image *Image, section string, prefix string, m Moby, idMap map[string]uint32, dupMap map[string]string, iw *tar.Writer, opts BuildOpts) error {
func outputImage(image *Image, section string, index int, prefix string, m Moby, idMap map[string]uint32, dupMap map[string]string, iw *tar.Writer, opts BuildOpts) error {
log.Infof(" Create OCI config for %s", image.Image)
imageName := util.ReferenceExpand(image.Image)
ref, err := reference.Parse(imageName)
@ -108,14 +108,15 @@ func outputImage(image *Image, section string, prefix string, m Moby, idMap map[
}
path := path.Join("containers", section, prefix+image.Name)
readonly := oci.Root.Readonly
err = ImageBundle(path, section, image.ref, config, runtime, iw, readonly, dupMap, opts)
err = ImageBundle(path, fmt.Sprintf("%s[%d]", section, index), image.ref, config, runtime, iw, readonly, dupMap, opts)
if err != nil {
return fmt.Errorf("failed to extract root filesystem for %s: %v", image.Image, err)
}
return nil
}
// Build performs the actual build process
// Build performs the actual build process. The output is the filesystem
// in a tar stream written to w.
func Build(m Moby, w io.Writer, opts BuildOpts) error {
if MobyDir == "" {
MobyDir = defaultMobyConfigDir()
@ -126,6 +127,68 @@ func Build(m Moby, w io.Writer, opts BuildOpts) error {
return err
}
// find the Moby config file from the existing tar
var metadataLocation string
if m.Files != nil {
for _, f := range m.Files {
if f.Metadata == "" {
continue
}
metadataLocation = strings.TrimPrefix(f.Path, "/")
}
}
var (
oldConfig *Moby
tmpfile *os.File
err error
)
if metadataLocation != "" && opts.InputTar != "" {
// copy the file over, in case it ends up being the same output
tmpfile, err = os.CreateTemp("", "linuxkit-input.tar")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
defer tmpfile.Close()
in, err := os.Open(opts.InputTar)
if err != nil {
return fmt.Errorf("failed to open input tar: %w", err)
}
if _, err := io.Copy(tmpfile, in); err != nil {
return fmt.Errorf("failed to copy input tar: %w", err)
}
if err := in.Close(); err != nil {
return fmt.Errorf("failed to close input file: %w", err)
}
if _, err := tmpfile.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning of tmpfile: %w", err)
}
// for efficiency, get the trimmed metadata path in advance
tmpTar := tar.NewReader(tmpfile)
// read the tar until we find the metadata file
for {
hdr, err := tmpTar.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read input tar: %w", err)
}
if strings.TrimPrefix(hdr.Name, "/") == metadataLocation {
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(tmpTar); err != nil {
return fmt.Errorf("failed to read metadata file from input tar: %w", err)
}
config, err := NewConfig(buf.Bytes(), nil)
if err != nil {
return fmt.Errorf("invalid config in existing tar file: %v", err)
}
oldConfig = &config
break
}
}
}
// do we have an inTar
iw := tar.NewWriter(w)
// add additions
@ -151,6 +214,13 @@ func Build(m Moby, w io.Writer, opts BuildOpts) error {
dupMap := map[string]string{}
if m.Kernel.ref != nil {
// first check if the existing one had it
//if config != nil && len(oldConfig.initRefs) > index+1 && oldConfig.initRefs[index].String() == image {
if oldConfig != nil && oldConfig.Kernel.ref != nil && oldConfig.Kernel.ref.String() == m.Kernel.ref.String() {
if err := extractPackageFilesFromTar(tmpfile, iw, m.Kernel.ref.String(), "kernel"); err != nil {
return err
}
} else {
// get kernel and initrd tarball and ucode cpio archive from container
log.Infof("Extract kernel image: %s", m.Kernel.ref)
kf := newKernelFilter(m.Kernel.ref, iw, m.Kernel.Cmdline, m.Kernel.Binary, m.Kernel.Tar, m.Kernel.UCode, opts.DecompressKernel)
@ -163,19 +233,26 @@ func Build(m Moby, w io.Writer, opts BuildOpts) error {
return fmt.Errorf("close error: %v", err)
}
}
}
// convert init images to tarballs
if len(m.Init) != 0 {
log.Infof("Add init containers:")
}
apkTar := newAPKTarWriter(iw, "init")
for _, ii := range m.initRefs {
for i, ii := range m.initRefs {
if oldConfig != nil && len(oldConfig.initRefs) > i && oldConfig.initRefs[i].String() == ii.String() {
if err := extractPackageFilesFromTar(tmpfile, apkTar, ii.String(), fmt.Sprintf("init[%d]", i)); err != nil {
return err
}
} else {
log.Infof("Process init image: %s", ii)
err := ImageTar("init", ii, "", apkTar, resolvconfSymlink, opts)
err := ImageTar(fmt.Sprintf("init[%d]", i), ii, "", apkTar, resolvconfSymlink, opts)
if err != nil {
return fmt.Errorf("failed to build init tarball from %s: %v", ii, err)
}
}
}
if err := apkTar.WriteAPKDB(); err != nil {
return err
}
@ -184,34 +261,51 @@ func Build(m Moby, w io.Writer, opts BuildOpts) error {
log.Infof("Add onboot containers:")
}
for i, image := range m.Onboot {
so := fmt.Sprintf("%03d", i)
if err := outputImage(image, "onboot", so+"-", m, idMap, dupMap, iw, opts); err != nil {
if oldConfig != nil && len(oldConfig.Onboot) > i && oldConfig.Onboot[i].Equal(image) {
if err := extractPackageFilesFromTar(tmpfile, iw, image.Image, fmt.Sprintf("onboot[%d]", i)); err != nil {
return err
}
} else {
so := fmt.Sprintf("%03d", i)
if err := outputImage(image, "onboot", i, so+"-", m, idMap, dupMap, iw, opts); err != nil {
return err
}
}
}
if len(m.Onshutdown) != 0 {
log.Infof("Add onshutdown containers:")
}
for i, image := range m.Onshutdown {
so := fmt.Sprintf("%03d", i)
if err := outputImage(image, "onshutdown", so+"-", m, idMap, dupMap, iw, opts); err != nil {
if oldConfig != nil && len(oldConfig.Onshutdown) > i && oldConfig.Onshutdown[i].Equal(image) {
if err := extractPackageFilesFromTar(tmpfile, iw, image.Image, fmt.Sprintf("onshutdown[%d]", i)); err != nil {
return err
}
} else {
so := fmt.Sprintf("%03d", i)
if err := outputImage(image, "onshutdown", i, so+"-", m, idMap, dupMap, iw, opts); err != nil {
return err
}
}
}
if len(m.Services) != 0 {
log.Infof("Add service containers:")
}
for _, image := range m.Services {
if err := outputImage(image, "services", "", m, idMap, dupMap, iw, opts); err != nil {
for i, image := range m.Services {
if oldConfig != nil && len(oldConfig.Services) > i && oldConfig.Services[i].Equal(image) {
if err := extractPackageFilesFromTar(tmpfile, iw, image.Image, fmt.Sprintf("services[%d]", i)); err != nil {
return err
}
} else {
if err := outputImage(image, "services", i, "", m, idMap, dupMap, iw, opts); err != nil {
return err
}
}
}
// add files
err := filesystem(m, iw, idMap)
if err != nil {
if err := filesystem(m, iw, idMap); err != nil {
return fmt.Errorf("failed to add filesystem parts: %v", err)
}
@ -711,3 +805,35 @@ func filesystem(m Moby, tw *tar.Writer, idMap map[string]uint32) error {
}
return nil
}
// extractPackageFilesFromTar reads files from the input tar and extracts those that have the correct
// PAXRecords - keys and values - to the tarWriter.
func extractPackageFilesFromTar(inTar *os.File, tw tarWriter, image, section string) error {
log.Infof("Copy %s files from input tar: %s", section, image)
// copy kernel files over
if _, err := inTar.Seek(0, 0); err != nil {
return fmt.Errorf("failed to seek to beginning of input tar: %w", err)
}
tr := tar.NewReader(inTar)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read input tar: %w", err)
}
if hdr.PAXRecords == nil {
continue
}
if hdr.PAXRecords[PaxRecordLinuxkitSource] == image && hdr.PAXRecords[PaxRecordLinuxkitLocation] == section {
if err := tw.WriteHeader(hdr); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
if _, err := io.Copy(tw, tr); err != nil {
return fmt.Errorf("failed to copy %s file: %w", section, err)
}
}
}
return nil
}

View File

@ -1,6 +1,7 @@
package moby
import (
"bytes"
"fmt"
"os"
"sort"
@ -62,6 +63,27 @@ type Image struct {
ImageConfig `yaml:",inline"`
}
// Equal check if another Image is functionally equal to this one.
// Takes the easy path by marshaling both into yaml and then comparing the yaml.
// There may be a more efficient way to do this, but this is simplest.
func (i *Image) Equal(o *Image) bool {
// if we are going to compare, we must canonicalized both image names
i0 := i
i0.Image = util.ReferenceExpand(i.Image)
iy, err := yaml.Marshal(i0)
if err != nil {
return false
}
o0 := o
o0.Image = util.ReferenceExpand(o.Image)
oy, err := yaml.Marshal(o)
if err != nil {
return false
}
return bytes.Equal(iy, oy)
}
// ImageConfig is the configuration part of Image, it is the subset
// which is valid in a "org.mobyproject.config" label on an image.
// Everything except Runtime and ref is used to build the OCI spec

View File

@ -9,4 +9,5 @@ type BuildOpts struct {
DockerCache bool
Arch string
SbomGenerator *SbomGenerator
InputTar string
}

View File

@ -0,0 +1,26 @@
#!/bin/sh
# SUMMARY: Check that tar output format build is reproducible after leveraging input tar
# LABELS:
set -e
# Source libraries. Uncomment if needed/defined
#. "${RT_LIB}"
. "${RT_PROJECT_ROOT}/_lib/lib.sh"
NAME=check
clean_up() {
rm -f ${NAME}*
}
trap clean_up EXIT
# do not include the sbom, because the SBoM unique IDs per file/package are *not* deterministic,
# (currently based upon syft), and thus will make the file non-reproducible
linuxkit build --no-sbom --format tar --o "${NAME}-1.tar" ../test.yml
linuxkit build --no-sbom --format tar --input-tar "${NAME}-1.tar" --o "${NAME}-2.tar" ../test.yml
diff -q "${NAME}-1.tar" "${NAME}-2.tar" || exit 1
exit 0

View File

@ -0,0 +1,18 @@
# testing --input-tar
This test works by building two tar files, and checking logs.
This only works because we use verbose logs.
The two files - `test1.yml` and `test2.yml` are identical, except for some changed lines.
The test script - `test.sh` - builds an image from `test1.yml`, then uses its output
as `--input-tar` for building from `test2.yml`. It then checks the output logs to make sure
that expected sections are copied over, and unexpected ones are not.
**Note:** If you make any changes to either test file, mark here and in `test.sh` so we know what has changed.
Changes:
- added one entry in `init`
- changed the command in `onboot[1]`
- removed `services[1]`, which causes `services[2]` to become `services[1]`, and thus should not be copied either, as order may matter.

View File

@ -0,0 +1,49 @@
#!/bin/sh
# SUMMARY: Check that tar output format build is reproducible after leveraging input tar
# LABELS:
set -e
# Source libraries. Uncomment if needed/defined
#. "${RT_LIB}"
. "${RT_PROJECT_ROOT}/_lib/lib.sh"
NAME=check
clean_up() {
rm -f ${NAME}-*.tar
}
trap clean_up EXIT
logfile=$(mktemp)
# do not include the sbom, because the SBoM unique IDs per file/package are *not* deterministic,
# (currently based upon syft), and thus will make the file non-reproducible
linuxkit build --no-sbom --format tar --o "${NAME}-1.tar" ./test1.yml
linuxkit build -v --no-sbom --format tar --input-tar "${NAME}-1.tar" --o "${NAME}-2.tar" ./test2.yml 2>&1 | tee ${logfile}
# the logfile should indicate which parts were copied and which not
# we only know this because we built the test2.yml manually
# should have 3 entries copied from init, but not a 4th
errors=""
grep -q "Copy init\[0\]" ${logfile} || errors="${errors}\nmissing Copy init[0]"
grep -q "Copy init\[1\]" ${logfile} || errors="${errors}\nmissing Copy init[1]"
grep -q "Copy init\[2\]" ${logfile} || errors="${errors}\nmissing Copy init[2]"
grep -q "Copy init\[3\]" ${logfile} && errors="${errors}\nunexpected Copy init[3]"
# should have one entry copied from onboot, but not a second
grep -q "Copy onboot\[0\]" ${logfile} || errors="${errors}\nmissing Copy onboot[0]"
grep -q "Copy onboot\[1\]" ${logfile} && errors="${errors}\nunexpected Copy onboot[1]"
# should have one entry copied from services, but not a second or third
grep -q "Copy services\[0\]" ${logfile} || errors="${errors}\nmissing Copy services[0]"
grep -q "Copy services\[1\]" ${logfile} && errors="${errors}\nunexpected Copy services[1]"
grep -q "Copy services\[2\]" ${logfile} && errors="${errors}\nunexpected Copy services[2]"
if [ -n "${errors}" ]; then
echo "Errors: ${errors}"
echo "logfile: ${logfile}"
exit 1
fi
exit 0

View File

@ -0,0 +1,37 @@
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0 console=ttyAMA0"
init:
- linuxkit/init:45a1ad5919f0b6acf0f0cf730e9434abfae11fe6
- linuxkit/runc:6062483d748609d505f2bcde4e52ee64a3329f5f
- linuxkit/containerd:e7a92d9f3282039eac5fb1b07cac2b8664cbf0ad
onboot:
- name: sysctl
image: linuxkit/sysctl:5a374e4bf3e5a7deeacff6571d0f30f7ea8f56db
- name: dhcpcd
image: linuxkit/dhcpcd:e9e3580f2de00e73e7b316a007186d22fea056ee
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1"]
onshutdown:
- name: shutdown
image: busybox:latest
command: ["/bin/echo", "so long and thanks for all the fish"]
services:
- name: getty
image: linuxkit/getty:5d86a2ce2d890c14ab66b13638dcadf74f29218b
env:
- INSECURE=true
- name: rngd
image: linuxkit/rngd:cdb919e4aee49fed0bf6075f0a104037cba83c39
- name: nginx
image: nginx:1.19.5-alpine
capabilities:
- CAP_NET_BIND_SERVICE
- CAP_CHOWN
- CAP_SETUID
- CAP_SETGID
- CAP_DAC_OVERRIDE
binds:
- /etc/resolv.conf:/etc/resolv.conf
files:
- path: etc/linuxkit-config
metadata: yaml

View File

@ -0,0 +1,36 @@
kernel:
image: linuxkit/kernel:6.6.13
cmdline: "console=tty0 console=ttyS0 console=ttyAMA0"
init:
- linuxkit/init:45a1ad5919f0b6acf0f0cf730e9434abfae11fe6
- linuxkit/runc:6062483d748609d505f2bcde4e52ee64a3329f5f
- linuxkit/containerd:e7a92d9f3282039eac5fb1b07cac2b8664cbf0ad
- linuxkit/ca-certificates:5aaa343474e5ac3ac01f8b917e82efb1063d80ff
onboot:
- name: sysctl
image: linuxkit/sysctl:5a374e4bf3e5a7deeacff6571d0f30f7ea8f56db
- name: dhcpcd
image: linuxkit/dhcpcd:e9e3580f2de00e73e7b316a007186d22fea056ee
command: ["/sbin/dhcpcd", "--nobackground", "-f", "/dhcpcd.conf", "-1", "change"]
onshutdown:
- name: shutdown
image: busybox:latest
command: ["/bin/echo", "so long and thanks for all the fish"]
services:
- name: getty
image: linuxkit/getty:5d86a2ce2d890c14ab66b13638dcadf74f29218b
env:
- INSECURE=true
- name: nginx
image: nginx:1.19.5-alpine
capabilities:
- CAP_NET_BIND_SERVICE
- CAP_CHOWN
- CAP_SETUID
- CAP_SETGID
- CAP_DAC_OVERRIDE
binds:
- /etc/resolv.conf:/etc/resolv.conf
files:
- path: etc/linuxkit-config
metadata: yaml