Skip to content

Commit

Permalink
feat: add build to life cycle hooks
Browse files Browse the repository at this point in the history
Add pre and post build life cycle hooks so the user can see when an image
build is occurring and what it's tag is.

Refactor combineContainerHooks to support additional fields automatically.

Reduce overhead of hook error handling by starting with an nil error
slice and use append to allocate only if needed, in other words assume
the happy path by default hence minimising allocations.

Minimise code used in hook handling by using helper methods.

Eliminate unused / shadowed parameters from defaultPreCreateHook.

GetRepo and GetTag now return a consistent UUID if the Repo or Tag field
aren't set by setting their values. This was needed to enable the output
of the image we're going to build in the log.
  • Loading branch information
stevenh committed Jul 17, 2024
1 parent c46039a commit 6384301
Show file tree
Hide file tree
Showing 4 changed files with 247 additions and 82 deletions.
32 changes: 16 additions & 16 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ type Container interface {
type ImageBuildInfo interface {
BuildOptions() (types.ImageBuildOptions, error) // converts the ImageBuildInfo to a types.ImageBuildOptions
GetContext() (io.Reader, error) // the path to the build context
GetDockerfile() string // the relative path to the Dockerfile, including the fileitself
GetDockerfile() string // the relative path to the Dockerfile, including the file itself
GetRepo() string // get repo label for image
GetTag() string // get tag label for image
ShouldPrintBuildLog() bool // allow build log to be printed to stdout
Expand Down Expand Up @@ -272,34 +272,34 @@ func (c *ContainerRequest) GetBuildArgs() map[string]*string {
return c.FromDockerfile.BuildArgs
}

// GetDockerfile returns the Dockerfile from the ContainerRequest, defaults to "Dockerfile"
// GetDockerfile returns the Dockerfile from the ContainerRequest, defaults to "Dockerfile".
// Sets FromDockerfile.Dockerfile to the default if blank.
func (c *ContainerRequest) GetDockerfile() string {
f := c.FromDockerfile.Dockerfile
if f == "" {
return "Dockerfile"
if c.FromDockerfile.Dockerfile == "" {
c.FromDockerfile.Dockerfile = "Dockerfile"
}

return f
return c.FromDockerfile.Dockerfile
}

// GetRepo returns the Repo label for image from the ContainerRequest, defaults to UUID
// GetRepo returns the Repo label for image from the ContainerRequest, defaults to UUID.
// Sets FromDockerfile.Repo to the default value if blank.
func (c *ContainerRequest) GetRepo() string {
r := c.FromDockerfile.Repo
if r == "" {
return uuid.NewString()
if c.FromDockerfile.Repo == "" {
c.FromDockerfile.Repo = uuid.NewString()
}

return strings.ToLower(r)
return strings.ToLower(c.FromDockerfile.Repo)
}

// GetTag returns the Tag label for image from the ContainerRequest, defaults to UUID
// GetTag returns the Tag label for image from the ContainerRequest, defaults to UUID.
// Sets FromDockerfile.Tag to the default value if blank.
func (c *ContainerRequest) GetTag() string {
t := c.FromDockerfile.Tag
if t == "" {
return uuid.NewString()
if c.FromDockerfile.Tag == "" {
c.FromDockerfile.Tag = uuid.NewString()
}

return strings.ToLower(t)
return strings.ToLower(c.FromDockerfile.Tag)
}

// Deprecated: Testcontainers will detect registry credentials automatically, and it will be removed in the next major release
Expand Down
30 changes: 25 additions & 5 deletions docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -1006,11 +1006,29 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque

var platform *specs.Platform

defaultHooks := []ContainerLifecycleHooks{
DefaultLoggingHook(p.Logger),
}

origLifecycleHooks := req.LifecycleHooks
req.LifecycleHooks = []ContainerLifecycleHooks{
combineContainerHooks(defaultHooks, req.LifecycleHooks),
}

if req.ShouldBuildImage() {
if err = req.buildingHook(ctx); err != nil {
return nil, err
}

imageName, err = p.BuildImage(ctx, &req)
if err != nil {
return nil, err
}

req.Image = imageName
if err = req.builtHook(ctx); err != nil {
return nil, err
}
} else {
for _, is := range req.ImageSubstitutors {
modifiedTag, err := is.Substitute(imageName)
Expand Down Expand Up @@ -1087,13 +1105,12 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
networkingConfig := &network.NetworkingConfig{}

// default hooks include logger hook and pre-create hook
defaultHooks := []ContainerLifecycleHooks{
DefaultLoggingHook(p.Logger),
defaultPreCreateHook(ctx, p, req, dockerInput, hostConfig, networkingConfig),
defaultHooks = append(defaultHooks,
defaultPreCreateHook(p, dockerInput, hostConfig, networkingConfig),
defaultCopyFileToContainerHook(req.Files),
defaultLogConsumersHook(req.LogConsumerCfg),
defaultReadinessHook(),
}
)

// in the case the container needs to access a local port
// we need to forward the local port to the container
Expand All @@ -1109,7 +1126,10 @@ func (p *DockerProvider) CreateContainer(ctx context.Context, req ContainerReque
defaultHooks = append(defaultHooks, sshdForwardPortsHook)
}

req.LifecycleHooks = []ContainerLifecycleHooks{combineContainerHooks(defaultHooks, req.LifecycleHooks)}
// Combine with the original LifecycleHooks to avoid duplicate logging hooks.
req.LifecycleHooks = []ContainerLifecycleHooks{
combineContainerHooks(defaultHooks, origLifecycleHooks),
}

err = req.creatingHook(ctx)
if err != nil {
Expand Down
163 changes: 103 additions & 60 deletions lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"reflect"
"strings"
"time"

Expand Down Expand Up @@ -39,6 +40,8 @@ type ContainerHook func(ctx context.Context, container Container) error
// to modify the container lifecycle. All the container lifecycle hooks except the PreCreates hooks
// will be passed to the container once it's created
type ContainerLifecycleHooks struct {
PreBuilds []ContainerRequestHook
PostBuilds []ContainerRequestHook
PreCreates []ContainerRequestHook
PostCreates []ContainerHook
PreStarts []ContainerHook
Expand All @@ -57,6 +60,18 @@ var DefaultLoggingHook = func(logger Logging) ContainerLifecycleHooks {
}

return ContainerLifecycleHooks{
PreBuilds: []ContainerRequestHook{
func(ctx context.Context, req ContainerRequest) error {
logger.Printf("🐳 Building image %s:%s", req.GetRepo(), req.GetTag())
return nil
},
},
PostBuilds: []ContainerRequestHook{
func(ctx context.Context, req ContainerRequest) error {
logger.Printf("✅ Built image %s", req.Image)
return nil
},
},
PreCreates: []ContainerRequestHook{
func(ctx context.Context, req ContainerRequest) error {
logger.Printf("🐳 Creating container for image %s", req.Image)
Expand Down Expand Up @@ -115,7 +130,7 @@ var DefaultLoggingHook = func(logger Logging) ContainerLifecycleHooks {
}

// defaultPreCreateHook is a hook that will apply the default configuration to the container
var defaultPreCreateHook = func(ctx context.Context, p *DockerProvider, req ContainerRequest, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) ContainerLifecycleHooks {
var defaultPreCreateHook = func(p *DockerProvider, dockerInput *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) ContainerLifecycleHooks {
return ContainerLifecycleHooks{
PreCreates: []ContainerRequestHook{
func(ctx context.Context, req ContainerRequest) error {
Expand Down Expand Up @@ -277,11 +292,34 @@ var defaultReadinessHook = func() ContainerLifecycleHooks {
}
}

// buildingHook is a hook that will be called before a container image is built.
func (req ContainerRequest) buildingHook(ctx context.Context) error {
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Building(ctx)(req)
})
}

// builtHook is a hook that will be called after a container image is built.
func (req ContainerRequest) builtHook(ctx context.Context) error {
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Built(ctx)(req)
})
}

// creatingHook is a hook that will be called before a container is created.
func (req ContainerRequest) creatingHook(ctx context.Context) error {
errs := make([]error, len(req.LifecycleHooks))
for i, lifecycleHooks := range req.LifecycleHooks {
errs[i] = lifecycleHooks.Creating(ctx)(req)
return req.applyLifecycleHooks(func(lifecycleHooks ContainerLifecycleHooks) error {
return lifecycleHooks.Creating(ctx)(req)
})
}

// applyLifecycleHooks calls hook on all LifecycleHooks.
func (req ContainerRequest) applyLifecycleHooks(hook func(lifecycleHooks ContainerLifecycleHooks) error) error {
var errs []error
for _, lifecycleHooks := range req.LifecycleHooks {
if err := hook(lifecycleHooks); err != nil {
errs = append(errs, err)
}
}

return errors.Join(errs...)
Expand Down Expand Up @@ -363,9 +401,11 @@ func (c *DockerContainer) terminatedHook(ctx context.Context) error {

// applyLifecycleHooks applies all lifecycle hooks reporting the container logs on error if logError is true.
func (c *DockerContainer) applyLifecycleHooks(ctx context.Context, logError bool, hooks func(lifecycleHooks ContainerLifecycleHooks) []ContainerHook) error {
errs := make([]error, len(c.lifecycleHooks))
for i, lifecycleHooks := range c.lifecycleHooks {
errs[i] = containerHookFn(ctx, hooks(lifecycleHooks))(c)
var errs []error
for _, lifecycleHooks := range c.lifecycleHooks {
if err := containerHookFn(ctx, hooks(lifecycleHooks))(c); err != nil {
errs = append(errs, err)
}
}

if err := errors.Join(errs...); err != nil {
Expand All @@ -387,10 +427,26 @@ func (c *DockerContainer) applyLifecycleHooks(ctx context.Context, logError bool
return nil
}

// Building is a hook that will be (called before a container image is built.
func (c ContainerLifecycleHooks) Building(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PreBuilds)
}

// Building is a hook that will be called before a container image is built.
func (c ContainerLifecycleHooks) Built(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PostBuilds)
}

// Creating is a hook that will be called before a container is created.
func (c ContainerLifecycleHooks) Creating(ctx context.Context) func(req ContainerRequest) error {
return containerRequestHook(ctx, c.PreCreates)
}

// containerRequestHook returns a function that will iterate over all
// the hooks and call them one by one until there is an error.
func containerRequestHook(ctx context.Context, hooks []ContainerRequestHook) func(req ContainerRequest) error {
return func(req ContainerRequest) error {
for _, hook := range c.PreCreates {
for _, hook := range hooks {
if err := hook(ctx, req); err != nil {
return err
}
Expand All @@ -404,9 +460,11 @@ func (c ContainerLifecycleHooks) Creating(ctx context.Context) func(req Containe
// container lifecycle hooks. The created function will iterate over all the hooks and call them one by one.
func containerHookFn(ctx context.Context, containerHook []ContainerHook) func(container Container) error {
return func(container Container) error {
errs := make([]error, len(containerHook))
for i, hook := range containerHook {
errs[i] = hook(ctx, container)
var errs []error
for _, hook := range containerHook {
if err := hook(ctx, container); err != nil {
errs = append(errs, err)
}
}

return errors.Join(errs...)
Expand Down Expand Up @@ -525,65 +583,50 @@ func (p *DockerProvider) preCreateContainerHook(ctx context.Context, req Contain
return nil
}

// combineContainerHooks it returns just one ContainerLifecycle hook, as the result of combining
// the default hooks with the user-defined hooks. The function will loop over all the default hooks,
// storing each of the hooks in a slice, and then it will loop over all the user-defined hooks,
// appending or prepending them to the slice of hooks. The order of hooks is the following:
// - for Pre-hooks, always run the default hooks first, then append the user-defined hooks
// - for Post-hooks, always run the user-defined hooks first, then the default hooks
// combineContainerHooks returns a ContainerLifecycle hook as the result
// of combining the default hooks with the user-defined hooks.
//
// The order of hooks is the following:
// - Pre-hooks run the default hooks first then the user-defined hooks
// - Post-hooks run the user-defined hooks first then the default hooks
func combineContainerHooks(defaultHooks, userDefinedHooks []ContainerLifecycleHooks) ContainerLifecycleHooks {
preCreates := []ContainerRequestHook{}
postCreates := []ContainerHook{}
preStarts := []ContainerHook{}
postStarts := []ContainerHook{}
postReadies := []ContainerHook{}
preStops := []ContainerHook{}
postStops := []ContainerHook{}
preTerminates := []ContainerHook{}
postTerminates := []ContainerHook{}

// We use reflection here to ensure that any new hooks are handled.
var hooks ContainerLifecycleHooks
hooksVal := reflect.ValueOf(&hooks).Elem()
hooksType := reflect.TypeOf(hooks)
for _, defaultHook := range defaultHooks {
preCreates = append(preCreates, defaultHook.PreCreates...)
preStarts = append(preStarts, defaultHook.PreStarts...)
preStops = append(preStops, defaultHook.PreStops...)
preTerminates = append(preTerminates, defaultHook.PreTerminates...)
defaultVal := reflect.ValueOf(defaultHook)
for i := 0; i < hooksType.NumField(); i++ {
if strings.HasPrefix(hooksType.Field(i).Name, "Pre") {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, defaultVal.Field(i)))
}
}
}

// append the user-defined hooks after the default pre-hooks
// and because the post hooks are still empty, the user-defined post-hooks
// will be the first ones to be executed
// Append the user-defined hooks after the default pre-hooks
// and because the post hooks are still empty, the user-defined
// post-hooks will be the first ones to be executed.
for _, userDefinedHook := range userDefinedHooks {
preCreates = append(preCreates, userDefinedHook.PreCreates...)
postCreates = append(postCreates, userDefinedHook.PostCreates...)
preStarts = append(preStarts, userDefinedHook.PreStarts...)
postStarts = append(postStarts, userDefinedHook.PostStarts...)
postReadies = append(postReadies, userDefinedHook.PostReadies...)
preStops = append(preStops, userDefinedHook.PreStops...)
postStops = append(postStops, userDefinedHook.PostStops...)
preTerminates = append(preTerminates, userDefinedHook.PreTerminates...)
postTerminates = append(postTerminates, userDefinedHook.PostTerminates...)
userVal := reflect.ValueOf(userDefinedHook)
for i := 0; i < hooksType.NumField(); i++ {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, userVal.Field(i)))
}
}

// finally, append the default post-hooks
// Finally, append the default post-hooks.
for _, defaultHook := range defaultHooks {
postCreates = append(postCreates, defaultHook.PostCreates...)
postStarts = append(postStarts, defaultHook.PostStarts...)
postReadies = append(postReadies, defaultHook.PostReadies...)
postStops = append(postStops, defaultHook.PostStops...)
postTerminates = append(postTerminates, defaultHook.PostTerminates...)
defaultVal := reflect.ValueOf(defaultHook)
for i := 0; i < hooksType.NumField(); i++ {
if strings.HasPrefix(hooksType.Field(i).Name, "Post") {
field := hooksVal.Field(i)
field.Set(reflect.AppendSlice(field, defaultVal.Field(i)))
}
}
}

return ContainerLifecycleHooks{
PreCreates: preCreates,
PostCreates: postCreates,
PreStarts: preStarts,
PostStarts: postStarts,
PostReadies: postReadies,
PreStops: preStops,
PostStops: postStops,
PreTerminates: preTerminates,
PostTerminates: postTerminates,
}
return hooks
}

func mergePortBindings(configPortMap, exposedPortMap nat.PortMap, exposedPorts []string) nat.PortMap {
Expand Down
Loading

0 comments on commit 6384301

Please sign in to comment.