feat(pre-deploy): add support for x-pre_deploy extension in Compose + e2e test

This commit is contained in:
Pasha Sviderski
2026-04-08 19:16:42 +10:00
parent 772b31b57f
commit 0e820f9c52
13 changed files with 371 additions and 4 deletions
+26
View File
@@ -0,0 +1,26 @@
package compose
import (
"fmt"
"github.com/compose-spec/compose-go/v2/types"
)
const PreDeployHookExtensionKey = "x-pre_deploy"
// PreDeployHook represents the parsed x-pre_deploy extension config.
type PreDeployHook struct {
Command types.ShellCommand `yaml:"command" json:"command"`
Environment types.MappingWithEquals `yaml:"environment,omitempty" json:"environment,omitempty"`
Privileged *bool `yaml:"privileged,omitempty" json:"privileged,omitempty"`
Timeout *types.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
User string `yaml:"user,omitempty" json:"user,omitempty"`
}
// Validate checks that the pre-deploy hook configuration is valid.
func (p *PreDeployHook) Validate() error {
if len(p.Command) == 0 {
return fmt.Errorf("missing required attribute 'command' in %s extension", PreDeployHookExtensionKey)
}
return nil
}
+149
View File
@@ -0,0 +1,149 @@
package compose
import (
"context"
"testing"
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPreDeployHookExtension(t *testing.T) {
tests := []struct {
name string
yaml string
want PreDeployHook
wantErr string
}{
{
name: "command only",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: ["echo", "hello"]
`,
want: PreDeployHook{
Command: types.ShellCommand{"echo", "hello"},
},
},
{
name: "command as string",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: echo hello
`,
want: PreDeployHook{
Command: types.ShellCommand{"echo", "hello"},
},
},
// TODO: explore ways to error on unknown attributes instead of ignoring them.
{
name: "all attributes",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: ["sh", "-c", "migrate up"]
environment:
DB_HOST: localhost
DB_PORT: "5432"
privileged: true
timeout: 2m30s
user: root
unknown_attribute: should be ignored
`,
want: PreDeployHook{
Command: types.ShellCommand{"sh", "-c", "migrate up"},
Environment: types.MappingWithEquals{
"DB_HOST": new("localhost"),
"DB_PORT": new("5432"),
},
Privileged: new(true),
Timeout: new(types.Duration(2*time.Minute + 30*time.Second)),
User: "root",
},
},
{
name: "timeout as seconds",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: ["true"]
timeout: 30s
`,
want: PreDeployHook{
Command: types.ShellCommand{"true"},
Timeout: new(types.Duration(30 * time.Second)),
},
},
{
name: "privileged false",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: ["true"]
privileged: false
`,
want: PreDeployHook{
Command: types.ShellCommand{"true"},
Privileged: new(false),
},
},
{
name: "missing command should fail",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
user: root
`,
wantErr: "missing required attribute 'command'",
},
{
name: "empty command should fail",
yaml: `
services:
web:
image: nginx
x-pre_deploy:
command: []
`,
wantErr: "missing required attribute 'command'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
project, err := LoadProjectFromContent(context.Background(), tt.yaml)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
service, err := project.GetService("web")
require.NoError(t, err)
ext, ok := service.Extensions[PreDeployHookExtensionKey]
require.True(t, ok, "x-pre_deploy extension not found")
hook, ok := ext.(PreDeployHook)
require.True(t, ok, "x-pre_deploy extension is not PreDeployHook type")
assert.Equal(t, tt.want, hook)
})
}
}
+1
View File
@@ -39,6 +39,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
composecli.WithExtension(CaddyExtensionKey, Caddy{}),
composecli.WithExtension(MachinesExtensionKey, MachinesSource{}),
composecli.WithExtension(PortsExtensionKey, PortsSource{}),
composecli.WithExtension(PreDeployHookExtensionKey, PreDeployHook{}),
}
options, err := composecli.NewProjectOptions(
+28 -1
View File
@@ -33,7 +33,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
return api.ServiceSpec{}, fmt.Errorf("unsupported pull policy: '%s'", service.PullPolicy)
}
env := make(map[string]string, len(service.Environment))
env := make(api.EnvVars, len(service.Environment))
for k, v := range service.Environment {
if v == nil {
// nil value means the variable misses a value in the compose file, and it hasn't been resolved
@@ -142,6 +142,27 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
spec.Configs = configSpecs
spec.Container.ConfigMounts = configMounts
if h, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
hook := &api.PreDeployHook{
Command: h.Command,
Privileged: h.Privileged,
User: h.User,
}
if h.Environment != nil {
hook.Env = make(api.EnvVars)
for k, v := range h.Environment {
if v != nil {
hook.Env[k] = *v
}
}
}
if h.Timeout != nil {
d := time.Duration(*h.Timeout)
hook.Timeout = &d
}
spec.PreDeploy = hook
}
return spec, nil
}
@@ -418,6 +439,12 @@ func validateServicesExtensions(project *types.Project) error {
"Host mode ports in 'x-caddy' can be used with 'x-caddy'", service.Name)
}
}
if hook, ok := service.Extensions[PreDeployHookExtensionKey].(PreDeployHook); ok {
if err := hook.Validate(); err != nil {
return fmt.Errorf("service '%s': %w", service.Name, err)
}
}
}
return nil
+7
View File
@@ -208,6 +208,13 @@ func TestServiceSpecFromCompose(t *testing.T) {
Placement: api.Placement{
Machines: []string{"machine-1", "machine-2"},
},
PreDeploy: &api.PreDeployHook{
Command: []string{"sh", "-c", "migrate"},
Env: api.EnvVars{"DB_HOST": "localhost"},
Privileged: new(false),
Timeout: new(2*time.Minute + 30*time.Second),
User: "root",
},
Replicas: 3,
UpdateConfig: api.UpdateConfig{
Order: api.UpdateOrderStopFirst,
+7
View File
@@ -78,6 +78,13 @@ services:
- test.example.com:80/https
- 8000/http
- 5000:3000@host
x-pre_deploy:
command: ["sh", "-c", "migrate"]
environment:
DB_HOST: localhost
privileged: false
timeout: 2m30s
user: root
test-caddy-config:
image: myapp:1.2.3
+1 -1
View File
@@ -258,7 +258,7 @@ func (cli *Client) InspectContainer(
}
prefixMatchCandidates := []api.MachineServiceContainer{}
for _, c := range svc.Containers {
for _, c := range append(svc.Containers, svc.HookContainers...) {
if c.Container.ID == containerNameOrID ||
c.Container.Name == containerNameOrID {
return c, nil
+1
View File
@@ -161,6 +161,7 @@ func (o *RunPreDeployOperation) Format() string {
return tui.BoldGreen.Render("▶") + " " +
tui.Faint.Render("run pre-deploy hook") + " " +
// TODO: truncate a long cmd to fit the width of the terminal.
o.Spec.Name + " (" + cmd + ") " +
tui.Faint.Render("on") + " " +
o.MachineName
+1 -1
View File
@@ -246,7 +246,7 @@ func (cli *Client) RemoveService(ctx context.Context, id string) error {
errCh := make(chan error)
// Remove all containers on all machines that belong to the service.
for _, mc := range svc.Containers {
for _, mc := range append(svc.Containers, svc.HookContainers...) {
wg.Go(func() {
err := cli.StopContainer(ctx, svc.ID, mc.Container.ID, container.StopOptions{})
if err != nil {