diff --git a/internal/cli/progress/event.go b/internal/cli/progress/event.go index 7b3ed8ba..fb584fb6 100644 --- a/internal/cli/progress/event.go +++ b/internal/cli/progress/event.go @@ -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)) } diff --git a/pkg/client/compose/predeploy.go b/pkg/client/compose/predeploy.go new file mode 100644 index 00000000..bb92ab9c --- /dev/null +++ b/pkg/client/compose/predeploy.go @@ -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 +} diff --git a/pkg/client/compose/predeploy_test.go b/pkg/client/compose/predeploy_test.go new file mode 100644 index 00000000..b7cb3bd9 --- /dev/null +++ b/pkg/client/compose/predeploy_test.go @@ -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) + }) + } +} diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index ecd50daa..0db727db 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -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( diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 110a9d24..80a9ae21 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -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 diff --git a/pkg/client/compose/service_test.go b/pkg/client/compose/service_test.go index 89206595..4f668f47 100644 --- a/pkg/client/compose/service_test.go +++ b/pkg/client/compose/service_test.go @@ -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, diff --git a/pkg/client/compose/testdata/compose-full-spec.yaml b/pkg/client/compose/testdata/compose-full-spec.yaml index 7e608eb8..19c643b0 100644 --- a/pkg/client/compose/testdata/compose-full-spec.yaml +++ b/pkg/client/compose/testdata/compose-full-spec.yaml @@ -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 diff --git a/pkg/client/container.go b/pkg/client/container.go index 712bab6c..feb9f283 100644 --- a/pkg/client/container.go +++ b/pkg/client/container.go @@ -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 diff --git a/pkg/client/deploy/operation/predeploy.go b/pkg/client/deploy/operation/predeploy.go index 7a78cc06..82d8f1a2 100644 --- a/pkg/client/deploy/operation/predeploy.go +++ b/pkg/client/deploy/operation/predeploy.go @@ -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 diff --git a/pkg/client/service.go b/pkg/client/service.go index cba0610a..e7c65920 100644 --- a/pkg/client/service.go +++ b/pkg/client/service.go @@ -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 { diff --git a/test/e2e/assert.go b/test/e2e/assert.go index 93191d65..1fc6bd5f 100644 --- a/test/e2e/assert.go +++ b/test/e2e/assert.go @@ -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) diff --git a/test/e2e/compose_deploy_test.go b/test/e2e/compose_deploy_test.go index f0834654..1e832729 100644 --- a/test/e2e/compose_deploy_test.go +++ b/test/e2e/compose_deploy_test.go @@ -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) + }) } diff --git a/test/e2e/fixtures/compose-predeploy.yaml b/test/e2e/fixtures/compose-predeploy.yaml new file mode 100644 index 00000000..3f608a94 --- /dev/null +++ b/test/e2e/fixtures/compose-predeploy.yaml @@ -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: