mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
feat(pre-deploy): add support for x-pre_deploy extension in Compose + e2e test
This commit is contained in:
@@ -8,5 +8,5 @@ import (
|
||||
|
||||
// PreDeployHookEventID returns a progress event identifier for pre-deploy hook operations.
|
||||
func PreDeployHookEventID(serviceName, machineName string) string {
|
||||
return fmt.Sprintf("Pre-deploy hook %s on %s", tui.NameStyle.Render(serviceName), tui.Bold.Render(machineName))
|
||||
return fmt.Sprintf("Pre-deploy hook for %s on %s", tui.NameStyle.Render(serviceName), tui.Bold.Render(machineName))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,10 @@ func assertServiceMatchesSpec(t *testing.T, svc api.Service, spec api.ServiceSpe
|
||||
for _, mc := range svc.Containers {
|
||||
assertContainerMatchesSpec(t, mc.Container, spec)
|
||||
}
|
||||
|
||||
if spec.PreDeploy != nil {
|
||||
assertHookContainersMatchSpec(t, svc, spec)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api.ServiceSpec) {
|
||||
@@ -139,6 +143,78 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
|
||||
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||
}
|
||||
|
||||
// assertHookContainersMatchSpec validates that hook containers in the service match the pre-deploy hook spec.
|
||||
func assertHookContainersMatchSpec(t *testing.T, svc api.Service, spec api.ServiceSpec) {
|
||||
t.Helper()
|
||||
require.NotEmpty(t, svc.HookContainers, "Expected at least one hook container")
|
||||
|
||||
for _, mc := range svc.HookContainers {
|
||||
ctr := mc.Container
|
||||
|
||||
// Verify labels.
|
||||
assert.True(t, api.ValidateServiceID(ctr.Config.Labels[api.LabelServiceID]))
|
||||
assert.Equal(t, spec.Name, ctr.Config.Labels[api.LabelServiceName])
|
||||
assert.Equal(t, api.LabelHookPreDeploy, ctr.Config.Labels[api.LabelHook])
|
||||
assert.Contains(t, ctr.Config.Labels, api.LabelManaged)
|
||||
assert.NotContains(t, ctr.Config.Labels, api.LabelServiceMode,
|
||||
"Hook containers should not have the service mode label")
|
||||
|
||||
assert.EqualValues(t, spec.PreDeploy.Command, ctr.Config.Cmd)
|
||||
if spec.Container.Entrypoint != nil {
|
||||
assert.EqualValues(t, spec.Container.Entrypoint, ctr.Config.Entrypoint)
|
||||
}
|
||||
|
||||
// Service env vars are inherited by hook containers.
|
||||
for _, env := range spec.Container.Env.ToSlice() {
|
||||
assert.Contains(t, ctr.Config.Env, env)
|
||||
}
|
||||
// Hook-specific env vars.
|
||||
for _, env := range spec.PreDeploy.Env.ToSlice() {
|
||||
assert.Contains(t, ctr.Config.Env, env)
|
||||
}
|
||||
assert.Contains(t, ctr.Config.Env, "UNCLOUD_HOOK_PRE_DEPLOY=true")
|
||||
|
||||
assert.Equal(t, spec.Container.Image, ctr.Config.Image)
|
||||
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
|
||||
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-pre-deploy-"),
|
||||
"Hook container name %q should start with %q", ctr.Name, spec.Name+"-pre-deploy-")
|
||||
|
||||
// Privileged is overridden by PreDeploy.Privileged if set, otherwise inherited from the service.
|
||||
if spec.PreDeploy.Privileged != nil {
|
||||
assert.Equal(t, *spec.PreDeploy.Privileged, ctr.HostConfig.Privileged)
|
||||
} else {
|
||||
assert.Equal(t, spec.Container.Privileged, ctr.HostConfig.Privileged)
|
||||
}
|
||||
|
||||
// User is overridden by PreDeploy.User if set.
|
||||
if spec.PreDeploy.User != "" {
|
||||
assert.Equal(t, spec.PreDeploy.User, ctr.Config.User)
|
||||
} else if spec.Container.User != "" {
|
||||
assert.Equal(t, spec.Container.User, ctr.Config.User)
|
||||
}
|
||||
|
||||
// Compute resources.
|
||||
assert.Equal(t, spec.Container.Resources.CPU, ctr.HostConfig.Resources.NanoCPUs)
|
||||
assert.Equal(t, spec.Container.Resources.Memory, ctr.HostConfig.Resources.Memory)
|
||||
assert.Equal(t, spec.Container.Resources.MemoryReservation, ctr.HostConfig.Resources.MemoryReservation)
|
||||
|
||||
// Hook-specific overrides: disabled restart, disabled healthcheck, no ports.
|
||||
assert.Equal(t, container.RestartPolicy{Name: container.RestartPolicyDisabled}, ctr.HostConfig.RestartPolicy)
|
||||
require.NotNil(t, ctr.Config.Healthcheck)
|
||||
assert.Equal(t, []string{"NONE"}, ctr.Config.Healthcheck.Test)
|
||||
assert.Empty(t, ctr.HostConfig.PortBindings)
|
||||
|
||||
assert.False(t, ctr.State.Running, "Hook container should not be running")
|
||||
assert.Equal(t, 0, ctr.State.ExitCode, "Hook container should exit with code 0")
|
||||
|
||||
assertContainerMountsMatchSpec(t, ctr.HostConfig.Mounts, spec)
|
||||
|
||||
// Verify network settings.
|
||||
assert.Len(t, ctr.NetworkSettings.Networks, 1)
|
||||
assert.Contains(t, ctr.NetworkSettings.Networks, machinedocker.NetworkName)
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainerMountsMatchSpec(t *testing.T, mounts []mount.Mount, spec api.ServiceSpec) {
|
||||
expectedMounts, err := machinedocker.ToDockerMounts(spec.Volumes, spec.Container.VolumeMounts)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -601,4 +601,60 @@ volumes:
|
||||
assert.ElementsMatch(t, machines.ToSlice(), expectedMachines,
|
||||
"Containers should be distributed across all machines")
|
||||
})
|
||||
|
||||
t.Run("pre-deploy hook", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
name := "test-compose-predeploy"
|
||||
volumeName := "test-compose-predeploy-data"
|
||||
t.Cleanup(func() {
|
||||
removeServices(t, cli, name)
|
||||
removeVolumes(t, cli, volumeName)
|
||||
})
|
||||
|
||||
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-predeploy.yaml"})
|
||||
require.NoError(t, err)
|
||||
|
||||
deployment, err := compose.NewDeployment(ctx, cli, project)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = deployment.Run(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc, err := cli.InspectService(ctx, name)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertServiceMatchesSpec(t, svc, api.ServiceSpec{
|
||||
Name: name,
|
||||
Container: api.ContainerSpec{
|
||||
Image: "busybox:1.37.0-uclibc",
|
||||
Command: []string{"sleep", "600"},
|
||||
Env: api.EnvVars{"SERVICE_VAR": "from-service"},
|
||||
VolumeMounts: []api.VolumeMount{
|
||||
{
|
||||
VolumeName: volumeName,
|
||||
ContainerPath: "/data",
|
||||
},
|
||||
},
|
||||
},
|
||||
Volumes: []api.VolumeSpec{
|
||||
{
|
||||
Name: volumeName,
|
||||
Type: api.VolumeTypeVolume,
|
||||
},
|
||||
},
|
||||
PreDeploy: &api.PreDeployHook{
|
||||
Command: []string{"sh", "-c", "echo hello-from-predeploy > /data/predeploy.txt"},
|
||||
Env: api.EnvVars{"HOOK_VAR": "from-hook"},
|
||||
User: "root",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify the pre-deploy hook wrote to the shared volume by reading the file from the running container.
|
||||
containerID := svc.Containers[0].Container.ID
|
||||
output, err := execInContainerAndReadOutput(t, ctx, cli, name, containerID,
|
||||
[]string{"cat", "/data/predeploy.txt"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello-from-predeploy\n", output)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
test-compose-predeploy:
|
||||
image: busybox:1.37.0-uclibc
|
||||
command: ["sleep", "600"]
|
||||
environment:
|
||||
SERVICE_VAR: from-service
|
||||
volumes:
|
||||
- test-compose-predeploy-data:/data
|
||||
x-pre_deploy:
|
||||
command: [sh, -c, echo hello-from-predeploy > /data/predeploy.txt]
|
||||
environment:
|
||||
HOOK_VAR: from-hook
|
||||
user: root
|
||||
timeout: 30s
|
||||
|
||||
volumes:
|
||||
test-compose-predeploy-data:
|
||||
Reference in New Issue
Block a user