Skip to content

Commit 3ce6430

Browse files
authored
chore: refinements in container lifecycle hooks (#78)
* feat: make container lifecycle configurable * chore: simplify setting credentials for pulling images * chore: refine Create hooks * chore: proper label name for client version * fix: lint * chore: validate just one auth config is returned for the image * fix: include empty credentials
1 parent d55ec69 commit 3ce6430

File tree

9 files changed

+261
-81
lines changed

9 files changed

+261
-81
lines changed

client/labels.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ package client
33
import "maps"
44

55
const (
6-
// LabelBase is the base label for all Docker labels.
6+
// LabelBase is the base label for all Docker SDK labels.
77
LabelBase = "com.docker.sdk"
88

9-
// LabelLang specifies the language which created the container.
9+
// LabelLang specifies the language.
1010
LabelLang = LabelBase + ".lang"
1111

12-
// LabelVersion specifies the version of go-sdk which created the container.
13-
LabelVersion = LabelBase + ".version"
12+
// LabelVersion specifies the version of go-sdk's client.
13+
LabelVersion = LabelBase + ".client"
1414
)
1515

1616
// sdkLabels is a map of labels that can be used to identify resources

container/container.run.go

Lines changed: 31 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,12 @@ package container
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67

7-
"github.com/containerd/errdefs"
8-
"github.com/containerd/platforms"
9-
108
"github.com/docker/docker/api/types/container"
11-
apiimage "github.com/docker/docker/api/types/image"
129
apinetwork "github.com/docker/docker/api/types/network"
1310
"github.com/docker/go-sdk/client"
14-
"github.com/docker/go-sdk/image"
1511
)
1612

1713
// Run is a convenience function that creates a new container and starts it.
@@ -23,6 +19,17 @@ func Run(ctx context.Context, opts ...ContainerCustomizer) (*Container, error) {
2319
started: true,
2420
}
2521

22+
// initialize the validate functions with the default ones
23+
def.validateFuncs = []func() error{
24+
func() error {
25+
if def.image == "" {
26+
return errors.New("image is required")
27+
}
28+
return nil
29+
},
30+
def.validateMounts,
31+
}
32+
2633
for _, opt := range opts {
2734
if err := opt.Customize(&def); err != nil {
2835
return nil, fmt.Errorf("customize: %w", err)
@@ -51,54 +58,6 @@ func Run(ctx context.Context, opts ...ContainerCustomizer) (*Container, error) {
5158
DefaultLoggingHook,
5259
}
5360

54-
for _, is := range def.imageSubstitutors {
55-
modifiedTag, err := is.Substitute(def.image)
56-
if err != nil {
57-
return nil, fmt.Errorf("failed to substitute image %s with %s: %w", def.image, is.Description(), err)
58-
}
59-
60-
if modifiedTag != def.image {
61-
def.dockerClient.Logger().Info("Replacing image", "description", is.Description(), "from", def.image, "to", modifiedTag)
62-
def.image = modifiedTag
63-
}
64-
}
65-
66-
var platform *platforms.Platform
67-
68-
if def.imagePlatform != "" {
69-
p, err := platforms.Parse(def.imagePlatform)
70-
if err != nil {
71-
return nil, fmt.Errorf("invalid platform %s: %w", def.imagePlatform, err)
72-
}
73-
platform = &p
74-
}
75-
76-
var shouldPullImage bool
77-
78-
if def.alwaysPullImage {
79-
shouldPullImage = true // If requested always attempt to pull image
80-
} else {
81-
img, err := def.dockerClient.ImageInspect(ctx, def.image)
82-
if err != nil {
83-
if !errdefs.IsNotFound(err) {
84-
return nil, err
85-
}
86-
shouldPullImage = true
87-
}
88-
if platform != nil && (img.Architecture != platform.Architecture || img.Os != platform.OS) {
89-
shouldPullImage = true
90-
}
91-
}
92-
93-
if shouldPullImage {
94-
pullOpt := apiimage.PullOptions{
95-
Platform: def.imagePlatform, // may be empty
96-
}
97-
if err := image.Pull(ctx, def.image, image.WithPullClient(def.dockerClient), image.WithPullOptions(pullOpt)); err != nil {
98-
return nil, err
99-
}
100-
}
101-
10261
def.labels[moduleLabel] = Version()
10362

10463
dockerInput := &container.Config{
@@ -131,7 +90,25 @@ func Run(ctx context.Context, opts ...ContainerCustomizer) (*Container, error) {
13190
return nil, err
13291
}
13392

134-
resp, err := def.dockerClient.ContainerCreate(ctx, dockerInput, hostConfig, networkingConfig, platform, def.name)
93+
// Image substitution must be done after the creating hook has been called,
94+
// as the image could have been overridden in there.
95+
for _, is := range def.imageSubstitutors {
96+
modifiedTag, err := is.Substitute(def.image)
97+
if err != nil {
98+
return nil, fmt.Errorf("failed to substitute image %s with %s: %w", def.image, is.Description(), err)
99+
}
100+
101+
if modifiedTag != def.image {
102+
def.dockerClient.Logger().Info("Replacing image", "description", is.Description(), "from", def.image, "to", modifiedTag)
103+
def.image = modifiedTag
104+
}
105+
}
106+
107+
// Update the image name in the docker input after the creating hook has been called,
108+
// as it could have been overridden in there.
109+
dockerInput.Image = def.image
110+
111+
resp, err := def.dockerClient.ContainerCreate(ctx, dockerInput, hostConfig, networkingConfig, def.platform, def.name)
135112
if err != nil {
136113
return nil, fmt.Errorf("container create: %w", err)
137114
}

container/definition.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"fmt"
66
"strings"
77

8+
"github.com/containerd/platforms"
9+
810
"github.com/docker/docker/api/types/container"
911
"github.com/docker/docker/api/types/network"
1012
"github.com/docker/go-sdk/client"
@@ -45,6 +47,9 @@ type Definition struct {
4547
// hostConfigModifier the modifier for the host config before container creation
4648
hostConfigModifier func(*container.HostConfig)
4749

50+
// validateFuncs the functions to validate the definition.
51+
validateFuncs []func() error
52+
4853
// imageSubstitutors the image substitutors to use for the container.
4954
imageSubstitutors []ImageSubstitutor
5055

@@ -72,6 +77,10 @@ type Definition struct {
7277
// imagePlatform the platform of the image
7378
imagePlatform string
7479

80+
// platform the platform of the container.
81+
// Used to override the platform of the image when building the container.
82+
platform *platforms.Platform
83+
7584
// name the name of the container.
7685
name string
7786

@@ -84,15 +93,14 @@ type Definition struct {
8493

8594
// validate validates the definition.
8695
func (d *Definition) validate() error {
87-
if d.image == "" {
88-
return errors.New("image is required")
89-
}
90-
91-
if err := d.validateMounts(); err != nil {
92-
return fmt.Errorf("validate mounts: %w", err)
96+
var errs []error
97+
for _, fn := range d.validateFuncs {
98+
if err := fn(); err != nil {
99+
errs = append(errs, err)
100+
}
93101
}
94102

95-
return nil
103+
return errors.Join(errs...)
96104
}
97105

98106
// DockerClient returns the docker client used by the definition.
@@ -105,6 +113,20 @@ func (d *Definition) Image() string {
105113
return d.image
106114
}
107115

116+
// ImageSubstitutors returns the image substitutors used by the definition.
117+
func (d *Definition) ImageSubstitutors() []ImageSubstitutor {
118+
return d.imageSubstitutors
119+
}
120+
121+
// Labels returns the labels used by the definition.
122+
func (d *Definition) Labels() map[string]string {
123+
if d.labels == nil {
124+
d.labels = make(map[string]string)
125+
}
126+
127+
return d.labels
128+
}
129+
108130
// Name returns the name of the container.
109131
func (d *Definition) Name() string {
110132
return d.name

container/lifecycle.go

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ package container
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"io"
78
"log/slog"
89
"reflect"
910
"strings"
1011
"time"
1112

13+
"github.com/containerd/errdefs"
14+
"github.com/containerd/platforms"
15+
16+
apiimage "github.com/docker/docker/api/types/image"
1217
"github.com/docker/go-sdk/container/exec"
1318
"github.com/docker/go-sdk/container/wait"
19+
"github.com/docker/go-sdk/image"
1420
)
1521

1622
type LifecycleHooks struct {
@@ -188,6 +194,10 @@ func combineContainerHooks(defaultHooks, userDefinedHooks []LifecycleHooks) Life
188194
}
189195
}
190196

197+
// Always append the default pull hook after any other pre-create hook.
198+
// User could have defined a build hook in which the image has not been defined yet.
199+
hooks.PreCreates = append(hooks.PreCreates, defaultPullHook...)
200+
191201
return hooks
192202
}
193203

@@ -202,13 +212,13 @@ func applyContainerHooks(ctx context.Context, hooks []ContainerHook, ctr *Contai
202212
}
203213

204214
func applyDefinitionHooks(ctx context.Context, hooks []DefinitionHook, def *Definition) error {
205-
var errs []error
206215
for _, hook := range hooks {
207216
if err := hook(ctx, def); err != nil {
208-
errs = append(errs, err)
217+
return fmt.Errorf("apply definition hook: %w", err)
209218
}
210219
}
211-
return errors.Join(errs...)
220+
221+
return nil
212222
}
213223

214224
// applyLifecycleHooks calls hook on all LifecycleHooks.
@@ -258,3 +268,48 @@ func (c *Container) applyLifecycleHooks(ctx context.Context, logError bool, hook
258268

259269
return nil
260270
}
271+
272+
// defaultPullHook is a hook that will pull the image if it is not present or if the platform is different.
273+
// It must be used as a [DefinitionHook] and not as a [ContainerHook] because it needs to be executed before the container is created.
274+
var defaultPullHook = []DefinitionHook{
275+
func(ctx context.Context, def *Definition) error {
276+
var platform *platforms.Platform
277+
278+
if def.imagePlatform != "" {
279+
p, err := platforms.Parse(def.imagePlatform)
280+
if err != nil {
281+
return fmt.Errorf("invalid platform %s: %w", def.imagePlatform, err)
282+
}
283+
platform = &p
284+
def.platform = platform
285+
}
286+
287+
var shouldPullImage bool
288+
289+
if def.alwaysPullImage {
290+
shouldPullImage = true // If requested always attempt to pull image
291+
} else {
292+
img, err := def.dockerClient.ImageInspect(ctx, def.image)
293+
if err != nil {
294+
if !errdefs.IsNotFound(err) {
295+
return err
296+
}
297+
shouldPullImage = true
298+
}
299+
if platform != nil && (img.Architecture != platform.Architecture || img.Os != platform.OS) {
300+
shouldPullImage = true
301+
}
302+
}
303+
304+
if shouldPullImage {
305+
pullOpt := apiimage.PullOptions{
306+
Platform: def.imagePlatform, // may be empty
307+
}
308+
if err := image.Pull(ctx, def.image, image.WithPullClient(def.dockerClient), image.WithPullOptions(pullOpt)); err != nil {
309+
return err
310+
}
311+
}
312+
313+
return nil
314+
},
315+
}

container/lifecycle_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"time"
88

99
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/go-sdk/client"
1012
)
1113

1214
func TestCombineLifecycleHooks(t *testing.T) {
@@ -51,7 +53,16 @@ func TestCombineLifecycleHooks(t *testing.T) {
5153
// call all the hooks in the right order, honouring the lifecycle
5254

5355
def := Definition{
56+
image: nginxAlpineImage,
5457
lifecycleHooks: []LifecycleHooks{combineContainerHooks(defaultHooks, userDefinedHooks)},
58+
// define a docker client to avoid the need to initialize the client
59+
dockerClient: client.DefaultClient,
60+
// avoid validation errors
61+
validateFuncs: []func() error{
62+
func() error {
63+
return nil
64+
},
65+
},
5566
}
5667
err := def.creatingHook(context.Background())
5768
require.NoError(t, err)

0 commit comments

Comments
 (0)