chore(volumes): map volume specs to container config, diff specs with volumes

This commit is contained in:
Pavel Sviderski
2025-04-09 21:51:20 +10:00
parent 13dea92311
commit c6205d4f57
5 changed files with 1024 additions and 63 deletions
+24
View File
@@ -1,6 +1,8 @@
package deploy
import (
"sort"
"github.com/psviderski/uncloud/pkg/api"
)
@@ -35,5 +37,27 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
return ContainerNeedsRecreate
}
// Compare volumes.
if len(current.Volumes) != len(new.Volumes) {
return ContainerNeedsRecreate
}
sortVolumes(current.Volumes)
sortVolumes(new.Volumes)
for i := range current.Volumes {
if !current.Volumes[i].Equals(new.Volumes[i]) {
// TODO: require only spec update for cases (see TODOs in tests):
// * bind volumes with different CreateHostPath
// * changed reference name in spec but preserved original volume name
// TODO: should defined but not used (no corresponding mounts) volumes be simply ignored?
return ContainerNeedsRecreate
}
}
return ContainerUpToDate
}
func sortVolumes(volumes []api.VolumeSpec) {
sort.Slice(volumes, func(i, j int) bool {
return volumes[i].Name < volumes[j].Name
})
}
+770
View File
@@ -0,0 +1,770 @@
package deploy
import (
"testing"
"github.com/docker/docker/api/types/mount"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/assert"
)
func TestEvalContainerSpecChange_Volumes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
current api.ServiceSpec
new api.ServiceSpec
expected ContainerSpecStatus
}{
// TODO: should all volumes that are defined but not used (no corresponding mounts) be simply ignored?
{
name: "identical volumes",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
expected: ContainerUpToDate,
},
{
name: "volumes in different order but identical",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
},
},
expected: ContainerUpToDate,
},
{
name: "different number of volumes",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeVolume,
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "no volumes to one volume",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "one volume to no volumes",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{},
},
expected: ContainerNeedsRecreate,
},
{
name: "change volume type",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "change volume options",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
NoCopy: false,
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
NoCopy: true,
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "change volume options to defaults",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data",
Driver: &mount.Driver{
Name: "local",
},
},
},
},
},
expected: ContainerUpToDate,
},
{
name: "change bind option CreateHostPath",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
CreateHostPath: true,
},
},
},
},
// TODO: this doesn't really require a recreate, only a spec update would be sufficient.
expected: ContainerNeedsRecreate,
},
{
name: "change bind option propagation",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
Propagation: mount.PropagationRPrivate,
},
},
},
},
// TODO: should we handle rprivate the same as empty propagation, hence up-to-date?
expected: ContainerNeedsRecreate,
},
{
name: "change tmpfs options",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "temp",
Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 1024 * 1024,
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "temp",
Type: api.VolumeTypeTmpfs,
TmpfsOptions: &mount.TmpfsOptions{
SizeBytes: 2 * 1024 * 1024,
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "changing volume name",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data-new",
Type: api.VolumeTypeVolume,
},
},
},
expected: ContainerNeedsRecreate,
},
// Tests for volume mounts
{
name: "identical volume mounts",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
expected: ContainerUpToDate,
},
{
name: "volume mounts in different order but identical",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data1",
ContainerPath: "/data1",
},
{
VolumeName: "data2",
ContainerPath: "/data2",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data2",
ContainerPath: "/data2",
},
{
VolumeName: "data1",
ContainerPath: "/data1",
},
},
},
},
expected: ContainerUpToDate,
},
{
name: "volumes and volume mounts in different order but identical",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data1",
ContainerPath: "/data1",
},
{
VolumeName: "data2",
ContainerPath: "/data2",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data2",
Type: api.VolumeTypeVolume,
},
{
Name: "data1",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data2",
ContainerPath: "/data2",
},
{
VolumeName: "data1",
ContainerPath: "/data1",
},
},
},
},
expected: ContainerUpToDate,
},
{
name: "added volume mount",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
{
VolumeName: "config",
ContainerPath: "/config",
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "removed volume mount",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
{
VolumeName: "config",
ContainerPath: "/config",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "changed volume mount container path",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/new/data/path",
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "changed volume mount read-only flag",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
ReadOnly: false,
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
ReadOnly: true,
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "changed mounted volume type",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/host/path",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
expected: ContainerNeedsRecreate,
},
{
name: "changed reference name in spec but preserved original volume name",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "new_name",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
Name: "data",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "new_name",
ContainerPath: "/data",
},
},
},
},
// TODO: this doesn't really require a recreate, only a spec update would be sufficient.
expected: ContainerNeedsRecreate,
},
{
name: "changed volumes, mounts and paths",
current: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
},
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/etc/app/config",
},
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/data",
},
{
VolumeName: "config",
ContainerPath: "/app/config",
ReadOnly: true,
},
},
},
},
new: api.ServiceSpec{
Volumes: []api.VolumeSpec{
{
Name: "data",
Type: api.VolumeTypeVolume,
VolumeOptions: &api.VolumeOptions{
NoCopy: true,
},
},
{
Name: "config",
Type: api.VolumeTypeBind,
BindOptions: &api.BindOptions{
HostPath: "/etc/app/new-config",
CreateHostPath: true,
},
},
{
Name: "logs",
Type: api.VolumeTypeVolume,
},
},
Container: api.ContainerSpec{
VolumeMounts: []api.VolumeMount{
{
VolumeName: "data",
ContainerPath: "/var/lib/app/data",
},
{
VolumeName: "config",
ContainerPath: "/app/config",
ReadOnly: true,
},
{
VolumeName: "logs",
ContainerPath: "/var/log/app",
},
},
},
},
expected: ContainerNeedsRecreate,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := EvalContainerSpecChange(tt.current, tt.new)
assert.Equal(t, tt.expected, result)
})
}
}