Add OverrideChanges and OverrideConfig to CommitOptions

Add an OverrideChanges and an OverrideConfig field to CommitOptions,
both of which can be used to make last-minute edits to the configuration
of an image that we're committing.

Signed-off-by: Nalin Dahyabhai <nalin@redhat.com>
This commit is contained in:
Nalin Dahyabhai 2023-11-01 10:18:40 -04:00
parent 07482ae885
commit 3a61cc0996
12 changed files with 1102 additions and 22 deletions

View File

@ -39,7 +39,7 @@ LIBSECCOMP_COMMIT := release-2.3
EXTRA_LDFLAGS ?=
BUILDAH_LDFLAGS := $(GO_LDFLAGS) '-X main.GitCommit=$(GIT_COMMIT) -X main.buildInfo=$(SOURCE_DATE_EPOCH) -X main.cniVersion=$(CNI_COMMIT) $(EXTRA_LDFLAGS)'
SOURCES=*.go imagebuildah/*.go bind/*.go chroot/*.go copier/*.go define/*.go docker/*.go internal/mkcw/*.go internal/mkcw/types/*.go internal/parse/*.go internal/source/*.go internal/util/*.go manifests/*.go pkg/chrootuser/*.go pkg/cli/*.go pkg/completion/*.go pkg/formats/*.go pkg/overlay/*.go pkg/parse/*.go pkg/rusage/*.go pkg/sshagent/*.go pkg/umask/*.go pkg/util/*.go util/*.go
SOURCES=*.go imagebuildah/*.go bind/*.go chroot/*.go copier/*.go define/*.go docker/*.go internal/config/*.go internal/mkcw/*.go internal/mkcw/types/*.go internal/parse/*.go internal/source/*.go internal/util/*.go manifests/*.go pkg/chrootuser/*.go pkg/cli/*.go pkg/completion/*.go pkg/formats/*.go pkg/overlay/*.go pkg/parse/*.go pkg/rusage/*.go pkg/sshagent/*.go pkg/umask/*.go pkg/util/*.go util/*.go
LINTFLAGS ?=

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
@ -13,6 +14,7 @@ import (
"github.com/containers/buildah/util"
"github.com/containers/common/pkg/auth"
"github.com/containers/common/pkg/completion"
"github.com/containers/image/v5/manifest"
"github.com/containers/image/v5/pkg/shortnames"
storageTransport "github.com/containers/image/v5/storage"
"github.com/containers/image/v5/transports/alltransports"
@ -26,6 +28,8 @@ type commitInputOptions struct {
omitHistory bool
blobCache string
certDir string
changes []string
configFile string
creds string
cwOptions string
disableCompression bool
@ -84,8 +88,11 @@ func commitListFlagSet(cmd *cobra.Command, opts *commitInputOptions) {
flags.IntSliceVar(&opts.encryptLayers, "encrypt-layer", nil, "layers to encrypt, 0-indexed layer indices with support for negative indexing (e.g. 0 is the first layer, -1 is the last layer). If not defined, will encrypt all layers if encryption-key flag is specified")
_ = cmd.RegisterFlagCompletionFunc("encryption-key", completion.AutocompleteNone)
flags.StringArrayVarP(&opts.changes, "change", "c", nil, "apply containerfile `instruction`s to the committed image")
flags.StringVar(&opts.certDir, "cert-dir", "", "use certificates at the specified path to access the registry")
_ = cmd.RegisterFlagCompletionFunc("cert-dir", completion.AutocompleteDefault)
flags.StringVar(&opts.configFile, "config", "", "apply configuration JSON `file` to the committed image")
_ = cmd.RegisterFlagCompletionFunc("config", completion.AutocompleteDefault)
flags.StringVar(&opts.creds, "creds", "", "use `[username[:password]]` for accessing the registry")
_ = cmd.RegisterFlagCompletionFunc("creds", completion.AutocompleteNone)
flags.StringVar(&opts.cwOptions, "cw", "", "confidential workload `options`")
@ -204,6 +211,18 @@ func commitCmd(c *cobra.Command, args []string, iopts commitInputOptions) error
return fmt.Errorf("unable to obtain encryption config: %w", err)
}
var overrideConfig *manifest.Schema2Config
if c.Flag("config").Changed {
configBytes, err := os.ReadFile(iopts.configFile)
if err != nil {
return fmt.Errorf("reading configuration blob from file: %w", err)
}
overrideConfig = &manifest.Schema2Config{}
if err := json.Unmarshal(configBytes, &overrideConfig); err != nil {
return fmt.Errorf("parsing configuration blob from %q: %w", iopts.configFile, err)
}
}
options := buildah.CommitOptions{
PreferredManifestType: format,
Manifest: iopts.manifest,
@ -218,6 +237,8 @@ func commitCmd(c *cobra.Command, args []string, iopts commitInputOptions) error
OciEncryptConfig: encConfig,
OciEncryptLayers: encLayers,
UnsetEnvs: iopts.unsetenvs,
OverrideChanges: iopts.changes,
OverrideConfig: overrideConfig,
}
exclusiveFlags := 0
if c.Flag("reference-time").Changed {

View File

@ -305,7 +305,7 @@ func updateConfig(builder *buildah.Builder, c *cobra.Command, iopts configResult
conditionallyAddHistory(builder, c, "/bin/sh -c #(nop) LABEL %s", strings.Join(iopts.label, " "))
}
// unset labels if any
for _, key := range iopts.unsetLabels {
for _, key := range iopts.unsetLabels {
builder.UnsetLabel(key)
}
if c.Flag("workingdir").Changed {

View File

@ -110,6 +110,14 @@ type CommitOptions struct {
// UnsetEnvs is a list of environments to not add to final image.
// Deprecated: use UnsetEnv() before committing instead.
UnsetEnvs []string
// OverrideConfig is an optional Schema2Config which can override parts
// of the working container's configuration for the image that is being
// committed.
OverrideConfig *manifest.Schema2Config
// OverrideChanges is a slice of Dockerfile-style instructions to make
// to the configuration of the image that is being committed, after
// OverrideConfig is applied.
OverrideChanges []string
}
var (

View File

@ -33,6 +33,18 @@ environment variable. `export REGISTRY_AUTH_FILE=path`
Use certificates at *path* (\*.crt, \*.cert, \*.key) to connect to the registry.
The default certificates directory is _/etc/containers/certs.d_.
**--change**, **-c** *"INSTRUCTION"*
Apply the change to the committed image that would have been made if it had
been built using a Containerfile which included the specified instruction.
This option can be specified multiple times.
**--config** *filename*
Read a JSON-encoded version of an image configuration object from the specified
file, and merge the values from it with the configuration of the image being
committed.
**--creds** *creds*
The [username[:password]] to use to authenticate with the registry if required.

View File

@ -16,6 +16,7 @@ import (
"github.com/containers/buildah/copier"
"github.com/containers/buildah/define"
"github.com/containers/buildah/docker"
"github.com/containers/buildah/internal/config"
"github.com/containers/buildah/internal/mkcw"
"github.com/containers/buildah/internal/tmpdir"
"github.com/containers/image/v5/docker/reference"
@ -79,6 +80,8 @@ type containerImageRef struct {
blobDirectory string
preEmptyLayers []v1.History
postEmptyLayers []v1.History
overrideChanges []string
overrideConfig *manifest.Schema2Config
}
type blobLayerInfo struct {
@ -298,6 +301,12 @@ func (i *containerImageRef) createConfigsAndManifests() (v1.Image, v1.Manifest,
dimage.History = []docker.V2S2History{}
}
// If we were supplied with a configuration, copy fields from it to
// matching fields in both formats.
if err := config.Override(dimage.Config, &oimage.Config, i.overrideChanges, i.overrideConfig); err != nil {
return v1.Image{}, v1.Manifest{}, docker.V2Image{}, docker.V2S2Manifest{}, fmt.Errorf("applying changes: %w", err)
}
// If we're producing a confidential workload, override the command and
// assorted other settings that aren't expected to work correctly.
if i.confidentialWorkload.Convert {
@ -924,6 +933,8 @@ func (b *Builder) makeContainerImageRef(options CommitOptions) (*containerImageR
blobDirectory: options.BlobDirectory,
preEmptyLayers: b.PrependedEmptyLayers,
postEmptyLayers: b.AppendedEmptyLayers,
overrideChanges: options.OverrideChanges,
overrideConfig: options.OverrideConfig,
}
return ref, nil
}

121
internal/config/convert.go Normal file
View File

@ -0,0 +1,121 @@
package config
import (
"github.com/containers/image/v5/manifest"
dockerclient "github.com/fsouza/go-dockerclient"
)
// Schema2ConfigFromGoDockerclientConfig converts a go-dockerclient Config
// structure to a manifest Schema2Config.
func Schema2ConfigFromGoDockerclientConfig(config *dockerclient.Config) *manifest.Schema2Config {
overrideExposedPorts := make(map[manifest.Schema2Port]struct{})
for port := range config.ExposedPorts {
overrideExposedPorts[manifest.Schema2Port(port)] = struct{}{}
}
var overrideHealthCheck *manifest.Schema2HealthConfig
if config.Healthcheck != nil {
overrideHealthCheck = &manifest.Schema2HealthConfig{
Test: config.Healthcheck.Test,
StartPeriod: config.Healthcheck.StartPeriod,
Interval: config.Healthcheck.Interval,
Timeout: config.Healthcheck.Timeout,
Retries: config.Healthcheck.Retries,
}
}
labels := make(map[string]string)
for k, v := range config.Labels {
labels[k] = v
}
volumes := make(map[string]struct{})
for v := range config.Volumes {
volumes[v] = struct{}{}
}
s2config := &manifest.Schema2Config{
Hostname: config.Hostname,
Domainname: config.Domainname,
User: config.User,
AttachStdin: config.AttachStdin,
AttachStdout: config.AttachStdout,
AttachStderr: config.AttachStderr,
ExposedPorts: overrideExposedPorts,
Tty: config.Tty,
OpenStdin: config.OpenStdin,
StdinOnce: config.StdinOnce,
Env: append([]string{}, config.Env...),
Cmd: append([]string{}, config.Cmd...),
Healthcheck: overrideHealthCheck,
ArgsEscaped: config.ArgsEscaped,
Image: config.Image,
Volumes: volumes,
WorkingDir: config.WorkingDir,
Entrypoint: append([]string{}, config.Entrypoint...),
NetworkDisabled: config.NetworkDisabled,
MacAddress: config.MacAddress,
OnBuild: append([]string{}, config.OnBuild...),
Labels: labels,
StopSignal: config.StopSignal,
Shell: config.Shell,
}
if config.StopTimeout != 0 {
s2config.StopTimeout = &config.StopTimeout
}
return s2config
}
// GoDockerclientConfigFromSchema2Config converts a manifest Schema2Config
// to a go-dockerclient config structure.
func GoDockerclientConfigFromSchema2Config(s2config *manifest.Schema2Config) *dockerclient.Config {
overrideExposedPorts := make(map[dockerclient.Port]struct{})
for port := range s2config.ExposedPorts {
overrideExposedPorts[dockerclient.Port(port)] = struct{}{}
}
var healthCheck *dockerclient.HealthConfig
if s2config.Healthcheck != nil {
healthCheck = &dockerclient.HealthConfig{
Test: s2config.Healthcheck.Test,
StartPeriod: s2config.Healthcheck.StartPeriod,
Interval: s2config.Healthcheck.Interval,
Timeout: s2config.Healthcheck.Timeout,
Retries: s2config.Healthcheck.Retries,
}
}
labels := make(map[string]string)
for k, v := range s2config.Labels {
labels[k] = v
}
volumes := make(map[string]struct{})
for v := range s2config.Volumes {
volumes[v] = struct{}{}
}
config := &dockerclient.Config{
Hostname: s2config.Hostname,
Domainname: s2config.Domainname,
User: s2config.User,
AttachStdin: s2config.AttachStdin,
AttachStdout: s2config.AttachStdout,
AttachStderr: s2config.AttachStderr,
PortSpecs: nil,
ExposedPorts: overrideExposedPorts,
Tty: s2config.Tty,
OpenStdin: s2config.OpenStdin,
StdinOnce: s2config.StdinOnce,
Env: append([]string{}, s2config.Env...),
Cmd: append([]string{}, s2config.Cmd...),
Healthcheck: healthCheck,
ArgsEscaped: s2config.ArgsEscaped,
Image: s2config.Image,
Volumes: volumes,
WorkingDir: s2config.WorkingDir,
Entrypoint: append([]string{}, s2config.Entrypoint...),
NetworkDisabled: s2config.NetworkDisabled,
MacAddress: s2config.MacAddress,
OnBuild: append([]string{}, s2config.OnBuild...),
Labels: labels,
StopSignal: s2config.StopSignal,
Shell: s2config.Shell,
}
if s2config.StopTimeout != nil {
config.StopTimeout = *s2config.StopTimeout
}
return config
}

View File

@ -0,0 +1,166 @@
package config
import (
"reflect"
"strconv"
"testing"
"github.com/containers/buildah/util"
"github.com/containers/image/v5/manifest"
dockerclient "github.com/fsouza/go-dockerclient"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fillAllFields recursively fills in 1 or "1" for every field in the passed-in
// structure, and that slices and maps have at least one value in them.
func fillAllFields[pStruct any](t *testing.T, st pStruct) {
v := reflect.ValueOf(st)
if v.Kind() == reflect.Pointer {
v = reflect.Indirect(v)
}
fillAllValueFields(t, v)
}
func fillAllValueFields(t *testing.T, v reflect.Value) {
fields := reflect.VisibleFields(v.Type())
for _, field := range fields {
if field.Anonymous {
// all right, fine, keep your secrets
continue
}
f := v.FieldByName(field.Name)
var keyType, elemType reflect.Type
if field.Type.Kind() == reflect.Map {
keyType = field.Type.Key()
}
switch field.Type.Kind() {
case reflect.Array, reflect.Chan, reflect.Map, reflect.Pointer, reflect.Slice:
elemType = field.Type.Elem()
}
fillValue(t, f, field.Name, field.Type.Kind(), keyType, elemType)
}
}
func fillValue(t *testing.T, value reflect.Value, name string, kind reflect.Kind, keyType, elemType reflect.Type) {
switch kind {
case reflect.Invalid,
reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, reflect.UnsafePointer,
reflect.Float32, reflect.Float64,
reflect.Complex64, reflect.Complex128:
require.NotEqualf(t, kind, kind, "unhandled %s field %s: tests require updating", kind, name)
case reflect.Bool:
value.SetBool(true)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
value.SetInt(1)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
value.SetUint(1)
case reflect.Map:
if value.IsNil() {
value.Set(reflect.MakeMap(value.Type()))
}
keyPtr := reflect.New(keyType)
key := reflect.Indirect(keyPtr)
fillValue(t, key, name, keyType.Kind(), nil, nil)
elemPtr := reflect.New(elemType)
elem := reflect.Indirect(elemPtr)
fillValue(t, elem, name, elemType.Kind(), nil, nil)
value.SetMapIndex(key, reflect.Indirect(elem))
case reflect.Slice:
vPtr := reflect.New(elemType)
v := reflect.Indirect(vPtr)
fillValue(t, v, name, elemType.Kind(), nil, nil)
value.Set(reflect.Append(reflect.MakeSlice(value.Type(), 0, 1), v))
case reflect.String:
value.SetString("1")
case reflect.Struct:
fillAllValueFields(t, value)
case reflect.Pointer:
p := reflect.New(elemType)
fillValue(t, reflect.Indirect(p), name, elemType.Kind(), nil, nil)
value.Set(p)
}
}
// checkAllFields recursively checks that every field not listed in allowZeroed
// is not set to its zero value, that every slice is not empty, and that every
// map has at least one entry. It makes an additional exception for structs
// which have no defined fields.
func checkAllFields[pStruct any](t *testing.T, st pStruct, allowZeroed []string) {
v := reflect.ValueOf(st)
if v.Kind() == reflect.Pointer {
v = reflect.Indirect(v)
}
checkAllValueFields(t, v, "", allowZeroed)
}
func checkAllValueFields(t *testing.T, v reflect.Value, name string, allowedToBeZero []string) {
fields := reflect.VisibleFields(v.Type())
for _, field := range fields {
if field.Anonymous {
// all right, fine, keep your secrets
continue
}
fieldName := field.Name
if name != "" {
fieldName = name + "." + field.Name
}
if util.StringInSlice(fieldName, allowedToBeZero) {
continue
}
f := v.FieldByName(field.Name)
var elemType reflect.Type
switch field.Type.Kind() {
case reflect.Array, reflect.Chan, reflect.Map, reflect.Pointer, reflect.Slice:
elemType = field.Type.Elem()
}
checkValue(t, f, fieldName, field.Type.Kind(), elemType, allowedToBeZero)
}
}
func checkValue(t *testing.T, value reflect.Value, name string, kind reflect.Kind, elemType reflect.Type, allowedToBeZero []string) {
if kind != reflect.Invalid {
switch kind {
case reflect.Map:
assert.Falsef(t, value.IsZero(), "map field %s not set when it was not already expected to be left unpopulated by conversion", name)
keys := value.MapKeys()
for i := 0; i < len(keys); i++ {
v := value.MapIndex(keys[i])
checkValue(t, v, name+"{"+keys[i].String()+"}", elemType.Kind(), nil, allowedToBeZero)
}
case reflect.Slice:
assert.Falsef(t, value.IsZero(), "slice field %s not set when it was not already expected to be left unpopulated by conversion", name)
for i := 0; i < value.Len(); i++ {
v := value.Index(i)
checkValue(t, v, name+"["+strconv.Itoa(i)+"]", elemType.Kind(), nil, allowedToBeZero)
}
case reflect.Struct:
if fields := reflect.VisibleFields(value.Type()); len(fields) != 0 {
// structs which are defined with no fields are okay
assert.Falsef(t, value.IsZero(), "slice field %s not set when it was not already expected to be left unpopulated by conversion", name)
}
checkAllValueFields(t, value, name, allowedToBeZero)
case reflect.Pointer:
assert.Falsef(t, value.IsZero(), "pointer field %s not set when it was not already expected to be left unpopulated by conversion", name)
checkValue(t, reflect.Indirect(value), name, elemType.Kind(), nil, allowedToBeZero)
}
}
}
func TestGoDockerclientConfigFromSchema2Config(t *testing.T) {
var input manifest.Schema2Config
fillAllFields(t, &input)
output := GoDockerclientConfigFromSchema2Config(&input)
// make exceptions for fields in "output" which have no corresponding field in "input"
notInSchema2Config := []string{"CPUSet", "CPUShares", "DNS", "Memory", "KernelMemory", "MemorySwap", "MemoryReservation", "Mounts", "PortSpecs", "PublishService", "SecurityOpts", "VolumeDriver", "VolumesFrom"}
checkAllFields(t, output, notInSchema2Config)
}
func TestSchema2ConfigFromGoDockerclientConfig(t *testing.T) {
var input dockerclient.Config
fillAllFields(t, &input)
output := Schema2ConfigFromGoDockerclientConfig(&input)
// make exceptions for fields in "output" which have no corresponding field in "input"
notInDockerConfig := []string{}
checkAllFields(t, output, notInDockerConfig)
}

View File

@ -0,0 +1,45 @@
package config
import (
"errors"
"fmt"
"os"
dockerclient "github.com/fsouza/go-dockerclient"
"github.com/openshift/imagebuilder"
)
// configOnlyExecutor implements the Executor interface that an
// imagebuilder.Builder expects to be able to call to do some heavy lifting,
// but it just refuses to do the work of ADD, COPY, or RUN. It also doesn't
// care if the working directory exists in a container, because it's really
// only concerned with letting the Builder's RunConfig get updated by changes
// from a Dockerfile. Try anything more than that and it'll return an error.
type configOnlyExecutor struct{}
func (g *configOnlyExecutor) Preserve(path string) error {
return errors.New("ADD/COPY/RUN not supported as changes")
}
func (g *configOnlyExecutor) EnsureContainerPath(path string) error {
return nil
}
func (g *configOnlyExecutor) EnsureContainerPathAs(path, user string, mode *os.FileMode) error {
return nil
}
func (g *configOnlyExecutor) Copy(excludes []string, copies ...imagebuilder.Copy) error {
if len(copies) == 0 {
return nil
}
return errors.New("ADD/COPY not supported as changes")
}
func (g *configOnlyExecutor) Run(run imagebuilder.Run, config dockerclient.Config) error {
return errors.New("RUN not supported as changes")
}
func (g *configOnlyExecutor) UnrecognizedInstruction(step *imagebuilder.Step) error {
return fmt.Errorf("did not understand change instruction %q", step.Original)
}

View File

@ -0,0 +1,5 @@
package config
import "github.com/openshift/imagebuilder"
var _ imagebuilder.Executor = &configOnlyExecutor{}

181
internal/config/override.go Normal file
View File

@ -0,0 +1,181 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/containers/buildah/docker"
"github.com/containers/image/v5/manifest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/openshift/imagebuilder"
)
// firstStringElseSecondString takes two strings, and returns the first
// string if it isn't empty, else the second string
func firstStringElseSecondString(first, second string) string {
if first != "" {
return first
}
return second
}
// firstSliceElseSecondSlice takes two string slices, and returns the first
// slice of strings if it has contents, else the second slice
func firstSliceElseSecondSlice(first, second []string) []string {
if len(first) > 0 {
return append([]string{}, first...)
}
return append([]string{}, second...)
}
// firstSlicePairElseSecondSlicePair takes two pairs of string slices, and
// returns the first pair of slices if either has contents, else the second
// pair
func firstSlicePairElseSecondSlicePair(firstA, firstB, secondA, secondB []string) ([]string, []string) {
if len(firstA) > 0 || len(firstB) > 0 {
return append([]string{}, firstA...), append([]string{}, firstB...)
}
return append([]string{}, secondA...), append([]string{}, secondB...)
}
// mergeEnv combines variables from a and b into a single environment slice. if
// a and b both provide values for the same variable, the value from b is
// preferred
func mergeEnv(a, b []string) []string {
index := make(map[string]int)
results := make([]string, 0, len(a)+len(b))
for _, kv := range append(append([]string{}, a...), b...) {
k, _, specifiesValue := strings.Cut(kv, "=")
if !specifiesValue {
if value, ok := os.LookupEnv(kv); ok {
kv = kv + "=" + value
} else {
kv = kv + "="
}
}
if i, seen := index[k]; seen {
results[i] = kv
} else {
index[k] = len(results)
results = append(results, kv)
}
}
return results
}
// Override takes a buildah docker config and an OCI ImageConfig, and applies a
// mixture of a slice of Dockerfile-style instructions and fields from a config
// blob to them both
func Override(dconfig *docker.Config, oconfig *v1.ImageConfig, overrideChanges []string, overrideConfig *manifest.Schema2Config) error {
if len(overrideChanges) > 0 {
if overrideConfig == nil {
overrideConfig = &manifest.Schema2Config{}
}
// Parse the set of changes as we would a Dockerfile.
changes := strings.Join(overrideChanges, "\n")
parsed, err := imagebuilder.ParseDockerfile(strings.NewReader(changes))
if err != nil {
return fmt.Errorf("parsing change set %+v: %w", changes, err)
}
// Create a dummy builder object to process configuration-related
// instructions.
subBuilder := imagebuilder.NewBuilder(nil)
// Convert the incoming data into an initial RunConfig.
subBuilder.RunConfig = *GoDockerclientConfigFromSchema2Config(overrideConfig)
// Process the change instructions one by one.
for _, node := range parsed.Children {
var step imagebuilder.Step
if err := step.Resolve(node); err != nil {
return fmt.Errorf("resolving change %q: %w", node.Original, err)
}
if err := subBuilder.Run(&step, &configOnlyExecutor{}, true); err != nil {
return fmt.Errorf("processing change %q: %w", node.Original, err)
}
}
// Pull settings out of the dummy builder's RunConfig.
overrideConfig = Schema2ConfigFromGoDockerclientConfig(&subBuilder.RunConfig)
}
if overrideConfig != nil {
// Apply changes from a possibly-provided possibly-changed config struct.
dconfig.Hostname = firstStringElseSecondString(overrideConfig.Hostname, dconfig.Hostname)
dconfig.Domainname = firstStringElseSecondString(overrideConfig.Domainname, dconfig.Domainname)
dconfig.User = firstStringElseSecondString(overrideConfig.User, dconfig.User)
oconfig.User = firstStringElseSecondString(overrideConfig.User, oconfig.User)
dconfig.AttachStdin = overrideConfig.AttachStdin
dconfig.AttachStdout = overrideConfig.AttachStdout
dconfig.AttachStderr = overrideConfig.AttachStderr
if len(overrideConfig.ExposedPorts) > 0 {
dexposedPorts := make(map[docker.Port]struct{})
oexposedPorts := make(map[string]struct{})
for port := range dconfig.ExposedPorts {
dexposedPorts[port] = struct{}{}
}
for port := range overrideConfig.ExposedPorts {
dexposedPorts[docker.Port(port)] = struct{}{}
}
for port := range oconfig.ExposedPorts {
oexposedPorts[port] = struct{}{}
}
for port := range overrideConfig.ExposedPorts {
oexposedPorts[string(port)] = struct{}{}
}
dconfig.ExposedPorts = dexposedPorts
oconfig.ExposedPorts = oexposedPorts
}
dconfig.Tty = overrideConfig.Tty
dconfig.OpenStdin = overrideConfig.OpenStdin
dconfig.StdinOnce = overrideConfig.StdinOnce
if len(overrideConfig.Env) > 0 {
dconfig.Env = mergeEnv(dconfig.Env, overrideConfig.Env)
oconfig.Env = mergeEnv(oconfig.Env, overrideConfig.Env)
}
dconfig.Entrypoint, dconfig.Cmd = firstSlicePairElseSecondSlicePair(overrideConfig.Entrypoint, overrideConfig.Cmd, dconfig.Entrypoint, dconfig.Cmd)
oconfig.Entrypoint, oconfig.Cmd = firstSlicePairElseSecondSlicePair(overrideConfig.Entrypoint, overrideConfig.Cmd, oconfig.Entrypoint, oconfig.Cmd)
if overrideConfig.Healthcheck != nil {
dconfig.Healthcheck = &docker.HealthConfig{
Test: append([]string{}, overrideConfig.Healthcheck.Test...),
Interval: overrideConfig.Healthcheck.Interval,
Timeout: overrideConfig.Healthcheck.Timeout,
StartPeriod: overrideConfig.Healthcheck.StartPeriod,
Retries: overrideConfig.Healthcheck.Retries,
}
}
dconfig.ArgsEscaped = overrideConfig.ArgsEscaped
dconfig.Image = firstStringElseSecondString(overrideConfig.Image, dconfig.Image)
if len(overrideConfig.Volumes) > 0 {
if dconfig.Volumes == nil {
dconfig.Volumes = make(map[string]struct{})
}
if oconfig.Volumes == nil {
oconfig.Volumes = make(map[string]struct{})
}
for volume := range overrideConfig.Volumes {
dconfig.Volumes[volume] = struct{}{}
oconfig.Volumes[volume] = struct{}{}
}
}
dconfig.WorkingDir = firstStringElseSecondString(overrideConfig.WorkingDir, dconfig.WorkingDir)
oconfig.WorkingDir = firstStringElseSecondString(overrideConfig.WorkingDir, oconfig.WorkingDir)
dconfig.NetworkDisabled = overrideConfig.NetworkDisabled
dconfig.MacAddress = overrideConfig.MacAddress
dconfig.OnBuild = overrideConfig.OnBuild
if len(overrideConfig.Labels) > 0 {
if dconfig.Labels == nil {
dconfig.Labels = make(map[string]string)
}
if oconfig.Labels == nil {
oconfig.Labels = make(map[string]string)
}
for k, v := range overrideConfig.Labels {
dconfig.Labels[k] = v
oconfig.Labels[k] = v
}
}
dconfig.StopSignal = overrideConfig.StopSignal
oconfig.StopSignal = overrideConfig.StopSignal
dconfig.StopTimeout = overrideConfig.StopTimeout
dconfig.Shell = firstSliceElseSecondSlice(overrideConfig.Shell, dconfig.Shell)
}
return nil
}

View File

@ -14,6 +14,7 @@ import (
"path/filepath"
"reflect"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
@ -25,12 +26,15 @@ import (
"github.com/containers/buildah/copier"
"github.com/containers/buildah/define"
"github.com/containers/buildah/imagebuildah"
"github.com/containers/buildah/internal/config"
"github.com/containers/image/v5/docker/daemon"
"github.com/containers/image/v5/image"
"github.com/containers/image/v5/manifest"
"github.com/containers/image/v5/pkg/compression"
is "github.com/containers/image/v5/storage"
istorage "github.com/containers/image/v5/storage"
"github.com/containers/image/v5/transports"
"github.com/containers/image/v5/transports/alltransports"
"github.com/containers/image/v5/types"
"github.com/containers/storage"
"github.com/containers/storage/pkg/archive"
@ -119,34 +123,37 @@ func TestMain(m *testing.M) {
flag.StringVar(&imagebuilderDir, "imagebuilder-dir", imagebuilderDir, "location to save imagebuilder build results")
flag.StringVar(&buildahDir, "buildah-dir", buildahDir, "location to save buildah build results")
flag.Parse()
var tempdir string
if buildahDir == "" || dockerDir == "" || imagebuilderDir == "" {
if tempdir == "" {
if tempdir, err = os.MkdirTemp("", "conformance"); err != nil {
logrus.Fatalf("creating temporary directory: %v", err)
os.Exit(1)
}
}
}
if buildahDir == "" {
buildahDir = filepath.Join(tempdir, "buildah")
}
if dockerDir == "" {
dockerDir = filepath.Join(tempdir, "docker")
}
if imagebuilderDir == "" {
imagebuilderDir = filepath.Join(tempdir, "imagebuilder")
}
level, err := logrus.ParseLevel(logLevel)
if err != nil {
logrus.Fatalf("error parsing log level %q: %v", logLevel, err)
}
logrus.SetLevel(level)
os.Exit(m.Run())
result := m.Run()
if err = os.RemoveAll(tempdir); err != nil {
logrus.Errorf("removing temporary directory %q: %v", tempdir, err)
}
os.Exit(result)
}
func TestConformance(t *testing.T) {
var tempdir string
if buildahDir == "" {
if tempdir == "" {
tempdir = t.TempDir()
}
buildahDir = filepath.Join(tempdir, "buildah")
}
if dockerDir == "" {
if tempdir == "" {
tempdir = t.TempDir()
}
dockerDir = filepath.Join(tempdir, "docker")
}
if imagebuilderDir == "" {
if tempdir == "" {
tempdir = t.TempDir()
}
imagebuilderDir = filepath.Join(tempdir, "imagebuilder")
}
dateStamp := fmt.Sprintf("%d", time.Now().UnixNano())
for i := range internalTestCases {
t.Run(internalTestCases[i].name, func(t *testing.T) {
@ -3075,3 +3082,506 @@ var internalTestCases = []testCase{
dockerUseBuildKit: true,
},
}
func TestCommit(t *testing.T) {
testCases := []struct {
description string
baseImage string
changes, derivedChanges []string
config, derivedConfig *docker.Config
}{
{
description: "defaults",
baseImage: "docker.io/library/busybox",
},
{
description: "empty change",
baseImage: "docker.io/library/busybox",
changes: []string{""},
},
{
description: "empty config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{},
},
{
description: "cmd just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"CMD /bin/imaginarySh"},
},
{
description: "cmd just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Cmd: []string{"/usr/bin/imaginarySh"},
},
},
{
description: "cmd conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"CMD /bin/imaginarySh"},
config: &docker.Config{
Cmd: []string{"/usr/bin/imaginarySh"},
},
},
{
description: "entrypoint just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"ENTRYPOINT /bin/imaginarySh"},
},
{
description: "entrypoint just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Entrypoint: []string{"/usr/bin/imaginarySh"},
},
},
{
description: "entrypoint conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"ENTRYPOINT /bin/imaginarySh"},
config: &docker.Config{
Entrypoint: []string{"/usr/bin/imaginarySh"},
},
},
{
description: "environment just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"ENV A=1", "ENV C=2"},
},
{
description: "environment just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Env: []string{"A=B"},
},
},
{
description: "environment with conflict union",
baseImage: "docker.io/library/busybox",
changes: []string{"ENV A=1", "ENV C=2"},
config: &docker.Config{
Env: []string{"A=B"},
},
},
{
description: "expose just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"EXPOSE 12345"},
},
{
description: "expose just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
ExposedPorts: map[docker.Port]struct{}{"23456": struct{}{}},
},
},
{
description: "expose union",
baseImage: "docker.io/library/busybox",
changes: []string{"EXPOSE 12345"},
config: &docker.Config{
ExposedPorts: map[docker.Port]struct{}{"23456": struct{}{}},
},
},
{
description: "healthcheck just changes",
baseImage: "docker.io/library/busybox",
changes: []string{`HEALTHCHECK --interval=1s --timeout=1s --start-period=1s --retries=1 CMD ["/bin/false"]`},
},
{
description: "healthcheck just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Healthcheck: &docker.HealthConfig{
Test: []string{"/bin/true"},
Interval: 2 * time.Second,
Timeout: 2 * time.Second,
StartPeriod: 2 * time.Second,
Retries: 2,
},
},
},
{
description: "healthcheck conflict",
baseImage: "docker.io/library/busybox",
changes: []string{`HEALTHCHECK --interval=1s --timeout=1s --start-period=1s --retries=1 CMD ["/bin/false"]`},
config: &docker.Config{
Healthcheck: &docker.HealthConfig{
Test: []string{"/bin/true"},
Interval: 2 * time.Second,
Timeout: 2 * time.Second,
StartPeriod: 2 * time.Second,
Retries: 2,
},
},
},
{
description: "label just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"LABEL A=1 C=2"},
},
{
description: "label just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Labels: map[string]string{"A": "B"},
},
},
{
description: "label with conflict union",
baseImage: "docker.io/library/busybox",
changes: []string{"LABEL A=1 C=2"},
config: &docker.Config{
Labels: map[string]string{"A": "B"},
},
},
// n.b. dockerd didn't like a MAINTAINER change, so no test for it, and it's not in a config blob
{
description: "onbuild just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"ONBUILD USER alice", "ONBUILD LABEL A=1"},
derivedChanges: []string{"LABEL C=3"},
},
{
description: "onbuild just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
OnBuild: []string{"USER bob", `CMD ["/bin/smash"]`, "LABEL B=2"},
},
derivedChanges: []string{"LABEL C=3"},
},
{
description: "onbuild conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"ONBUILD USER alice", "ONBUILD LABEL A=1"},
config: &docker.Config{
OnBuild: []string{"USER bob", `CMD ["/bin/smash"]`, "LABEL B=2"},
},
derivedChanges: []string{"LABEL C=3"},
},
// n.b. dockerd didn't like a SHELL change, so no test for it or a conflict with a config blob
{
description: "shell just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Shell: []string{"/usr/bin/imaginarySh"},
},
},
{
description: "stop signal conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"STOPSIGNAL SIGTERM"},
config: &docker.Config{
StopSignal: "SIGKILL",
},
},
{
description: "stop timeout=0",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
StopTimeout: 0,
},
},
{
description: "stop timeout=15",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
StopTimeout: 15,
},
},
{
description: "stop timeout=15, then 0",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
StopTimeout: 15,
},
derivedConfig: &docker.Config{
StopTimeout: 0,
},
},
{
description: "stop timeout=0, then 15",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
StopTimeout: 0,
},
derivedConfig: &docker.Config{
StopTimeout: 15,
},
},
{
description: "user just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"USER 1001:1001"},
},
{
description: "user just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
User: "1000:1000",
},
},
{
description: "user with conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"USER 1001:1001"},
config: &docker.Config{
User: "1000:1000",
},
},
{
description: "volume just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"VOLUME /a-volume"},
},
{
description: "volume just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
Volumes: map[string]struct{}{"/b-volume": struct{}{}},
},
},
{
description: "volume union",
baseImage: "docker.io/library/busybox",
changes: []string{"VOLUME /a-volume"},
config: &docker.Config{
Volumes: map[string]struct{}{"/b-volume": struct{}{}},
},
},
{
description: "workdir just changes",
baseImage: "docker.io/library/busybox",
changes: []string{"WORKDIR /yeah"},
},
{
description: "workdir just config",
baseImage: "docker.io/library/busybox",
config: &docker.Config{
WorkingDir: "/naw",
},
},
{
description: "workdir with conflict",
baseImage: "docker.io/library/busybox",
changes: []string{"WORKDIR /yeah"},
config: &docker.Config{
WorkingDir: "/naw",
},
},
}
var tempdir string
buildahDir := buildahDir
if buildahDir == "" {
if tempdir == "" {
tempdir = t.TempDir()
}
buildahDir = filepath.Join(tempdir, "buildah")
}
dockerDir := dockerDir
if dockerDir == "" {
if tempdir == "" {
tempdir = t.TempDir()
}
dockerDir = filepath.Join(tempdir, "docker")
}
ctx := context.TODO()
// connect to dockerd using go-dockerclient
client, err := docker.NewClientFromEnv()
require.NoErrorf(t, err, "unable to initialize docker client")
var dockerVersion []string
if version, err := client.Version(); err == nil {
if version != nil {
for _, s := range *version {
dockerVersion = append(dockerVersion, s)
}
}
} else {
require.NoErrorf(t, err, "unable to connect to docker daemon")
}
// find a new place to store buildah builds
tempdir = t.TempDir()
// create subdirectories to use for buildah storage
rootDir := filepath.Join(tempdir, "root")
runrootDir := filepath.Join(tempdir, "runroot")
// initialize storage for buildah
options := storage.StoreOptions{
GraphDriverName: os.Getenv("STORAGE_DRIVER"),
GraphRoot: rootDir,
RunRoot: runrootDir,
RootlessStoragePath: rootDir,
}
store, err := storage.GetStore(options)
require.NoErrorf(t, err, "error creating buildah storage at %q", rootDir)
defer func() {
if store != nil {
_, err := store.Shutdown(true)
require.NoErrorf(t, err, "error shutting down storage for buildah")
}
}()
// walk through test cases
for testIndex, testCase := range testCases {
t.Run(testCase.description, func(t *testing.T) {
test := testCases[testIndex]
// create the test container, then commit it, using the docker client
baseImage := test.baseImage
repository, tag := docker.ParseRepositoryTag(baseImage)
if tag == "" {
tag = "latest"
}
baseImage = repository + ":" + tag
if _, err := client.InspectImage(test.baseImage); err != nil && errors.Is(err, docker.ErrNoSuchImage) {
// oh, we need to pull the base image
err = client.PullImage(docker.PullImageOptions{
Repository: repository,
Tag: tag,
}, docker.AuthConfiguration{})
require.NoErrorf(t, err, "pulling base image")
}
container, err := client.CreateContainer(docker.CreateContainerOptions{
Context: ctx,
Config: &docker.Config{
Image: baseImage,
},
})
require.NoErrorf(t, err, "creating the working container with docker")
if err == nil {
defer func(containerName string) {
err := client.RemoveContainer(docker.RemoveContainerOptions{
ID: containerName,
Force: true,
})
assert.Nil(t, err, "error deleting working docker container %q", containerName)
}(container.ID)
}
dockerImageName := "committed:" + strconv.Itoa(testIndex)
dockerImage, err := client.CommitContainer(docker.CommitContainerOptions{
Container: container.ID,
Changes: test.changes,
Run: test.config,
Repository: dockerImageName,
})
assert.NoErrorf(t, err, "committing the working container with docker")
if err == nil {
defer func(dockerImageName string) {
err := client.RemoveImageExtended(dockerImageName, docker.RemoveImageOptions{
Context: ctx,
Force: true,
})
assert.Nil(t, err, "error deleting newly-built docker image %q", dockerImage.ID)
}(dockerImageName)
}
dockerRef, err := alltransports.ParseImageName("docker-daemon:" + dockerImageName)
assert.NoErrorf(t, err, "parsing name of newly-committed docker image")
if len(test.derivedChanges) > 0 || test.derivedConfig != nil {
container, err := client.CreateContainer(docker.CreateContainerOptions{
Context: ctx,
Config: &docker.Config{
Image: dockerImage.ID,
},
})
require.NoErrorf(t, err, "creating the derived container with docker")
if err == nil {
defer func(containerName string) {
err := client.RemoveContainer(docker.RemoveContainerOptions{
ID: containerName,
Force: true,
})
assert.Nil(t, err, "error deleting derived docker container %q", containerName)
}(container.ID)
}
derivedImageName := "derived:" + strconv.Itoa(testIndex)
derivedImage, err := client.CommitContainer(docker.CommitContainerOptions{
Container: container.ID,
Changes: test.derivedChanges,
Run: test.derivedConfig,
Repository: derivedImageName,
})
assert.NoErrorf(t, err, "committing the derived container with docker")
defer func(derivedImageName string) {
err := client.RemoveImageExtended(derivedImageName, docker.RemoveImageOptions{
Context: ctx,
Force: true,
})
assert.Nil(t, err, "error deleting newly-derived docker image %q", derivedImage.ID)
}(derivedImageName)
dockerRef, err = alltransports.ParseImageName("docker-daemon:" + derivedImageName)
assert.NoErrorf(t, err, "parsing name of newly-derived docker image")
}
// create the test container, then commit it, using the buildah API
builder, err := buildah.NewBuilder(ctx, store, buildah.BuilderOptions{
FromImage: baseImage,
})
require.NoErrorf(t, err, "creating the working container with buildah")
defer func(builder *buildah.Builder) {
err := builder.Delete()
assert.NoErrorf(t, err, "removing the working container")
}(builder)
var overrideConfig *manifest.Schema2Config
if test.config != nil {
overrideConfig = config.Schema2ConfigFromGoDockerclientConfig(test.config)
}
buildahID, _, _, err := builder.Commit(ctx, nil, buildah.CommitOptions{
PreferredManifestType: manifest.DockerV2Schema2MediaType,
OverrideChanges: test.changes,
OverrideConfig: overrideConfig,
})
assert.NoErrorf(t, err, "committing buildah image")
buildahRef, err := is.Transport.NewStoreReference(store, nil, buildahID)
assert.NoErrorf(t, err, "parsing name of newly-built buildah image")
if len(test.derivedChanges) > 0 || test.derivedConfig != nil {
derivedBuilder, err := buildah.NewBuilder(ctx, store, buildah.BuilderOptions{
FromImage: buildahID,
})
defer func(builder *buildah.Builder) {
err := builder.Delete()
assert.NoErrorf(t, err, "removing the derived container")
}(derivedBuilder)
require.NoErrorf(t, err, "creating the derived container with buildah")
var overrideConfig *manifest.Schema2Config
if test.derivedConfig != nil {
overrideConfig = config.Schema2ConfigFromGoDockerclientConfig(test.derivedConfig)
}
derivedID, _, _, err := builder.Commit(ctx, nil, buildah.CommitOptions{
PreferredManifestType: manifest.DockerV2Schema2MediaType,
OverrideChanges: test.derivedChanges,
OverrideConfig: overrideConfig,
})
assert.NoErrorf(t, err, "committing derived buildah image")
buildahRef, err = is.Transport.NewStoreReference(store, nil, derivedID)
assert.NoErrorf(t, err, "parsing name of newly-derived buildah image")
}
// scan the images
saveReport(ctx, t, dockerRef, filepath.Join(dockerDir, t.Name()), []byte{}, []byte{}, dockerVersion)
saveReport(ctx, t, buildahRef, filepath.Join(buildahDir, t.Name()), []byte{}, []byte{}, dockerVersion)
// compare the scans
_, originalDockerConfig, ociDockerConfig, fsDocker := readReport(t, filepath.Join(dockerDir, t.Name()))
_, originalBuildahConfig, ociBuildahConfig, fsBuildah := readReport(t, filepath.Join(buildahDir, t.Name()))
miss, left, diff, same := compareJSON(originalDockerConfig, originalBuildahConfig, originalSkip)
if !same {
assert.Failf(t, "Image configurations differ as committed in Docker format", configCompareResult(miss, left, diff, "buildah"))
}
miss, left, diff, same = compareJSON(ociDockerConfig, ociBuildahConfig, ociSkip)
if !same {
assert.Failf(t, "Image configurations differ when converted to OCI format", configCompareResult(miss, left, diff, "buildah"))
}
miss, left, diff, same = compareJSON(fsDocker, fsBuildah, fsSkip)
if !same {
assert.Failf(t, "Filesystem contents differ", fsCompareResult(miss, left, diff, "buildah"))
}
})
}
}