mirror of
https://github.com/psviderski/uncloud.git
synced 2026-11-10 12:44:16 +00:00
406 lines
17 KiB
Go
406 lines
17 KiB
Go
package image
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestPlanImageRemovalFromInventory_Selectors(t *testing.T) {
|
|
idA, idB := testImageID("a"), testImageID("b")
|
|
digestRef := "myapp@" + testImageID("c")
|
|
tests := []struct {
|
|
name string
|
|
selector string
|
|
inventory []*machineImageInventory
|
|
want []removeImageOperation
|
|
}{
|
|
{
|
|
name: "repository selects all stored names across images and machines", selector: "myapp",
|
|
inventory: []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "other:keep"),
|
|
testImage(idB, "myapp:latest"),
|
|
testImage(testImageID("c"), digestRef),
|
|
testImage(testImageID("d"), "myapp-extra:latest", "team/myapp:latest", "myapp/worker:latest"),
|
|
}),
|
|
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:test")}),
|
|
},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idB, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "machine2", ImageID: idB, Image: "myapp:test"},
|
|
{MachineName: "dev1", ImageID: testImageID("c"), Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "repository without latest includes its tags and stored digest", selector: "myapp",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", digestRef, "other:keep"),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "explicit latest selects only that tag", selector: "myapp:latest",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "myapp:latest", digestRef),
|
|
})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
|
|
},
|
|
{
|
|
name: "qualified repository selects familiar names", selector: "docker.io/library/myapp",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "myapp:latest", "other:keep"),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
},
|
|
},
|
|
{
|
|
name: "registry port does not count as an explicit tag", selector: "registry.example.com:5000/team/myapp",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new",
|
|
"registry.example.com:5001/team/myapp:keep", "registry.example.com:5000/team/myapp/worker:keep"),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:new"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"},
|
|
},
|
|
},
|
|
{
|
|
name: "explicit tag with registry port stays exact", selector: "registry.example.com:5000/team/myapp:old",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "registry.example.com:5000/team/myapp:old", "registry.example.com:5000/team/myapp:new"),
|
|
})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "registry.example.com:5000/team/myapp:old"}},
|
|
},
|
|
{
|
|
name: "tag uses each machines local image and allows missing copies", selector: "myapp:old",
|
|
inventory: []*machineImageInventory{
|
|
testMachineInventory("machine2", []image.Summary{testImage(idB, "myapp:old")}),
|
|
testMachineInventory("empty", nil),
|
|
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
|
|
},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "machine2", ImageID: idB, Image: "myapp:old"},
|
|
},
|
|
},
|
|
{
|
|
name: "qualified names use familiar references", selector: "docker.io/library/myapp:old",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old"),
|
|
})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
|
},
|
|
{
|
|
name: "full ID expands all repositories and deduplicates references per machine", selector: idA,
|
|
inventory: []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{
|
|
{
|
|
ID: idA,
|
|
RepoTags: []string{"other:keep", "myapp:old", "myapp:old", digestRef, "other@" + testImageID("c")},
|
|
RepoDigests: []string{
|
|
digestRef, "other@" + testImageID("c"), "other@" + testImageID("c"), testImageID("c"),
|
|
},
|
|
},
|
|
testImage(idB, "unrelated:keep"),
|
|
}),
|
|
testMachineInventory("machine2", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:old", digestRef}, RepoDigests: []string{digestRef},
|
|
}}),
|
|
},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "machine2", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "other:keep"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
{MachineName: "machine2", ImageID: idA, Image: digestRef},
|
|
{MachineName: "dev1", ImageID: idA, Image: "other@" + testImageID("c")},
|
|
},
|
|
},
|
|
{
|
|
name: "same full ID on multiple machines is unambiguous", selector: "aaaaaaaa",
|
|
inventory: []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{testImage(idA, "myapp:old")}),
|
|
testMachineInventory("dev1", []image.Summary{testImage(idB, "myapp:latest")}),
|
|
testMachineInventory("machine2", []image.Summary{testImage(idA, "other:test")}),
|
|
},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "machine2", ImageID: idA, Image: "other:test"},
|
|
},
|
|
},
|
|
{
|
|
name: "short ID with algorithm prefix", selector: "sha256:aaaaaaaa",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1",
|
|
[]image.Summary{testImage(idA, "myapp:old")})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
|
},
|
|
{
|
|
name: "unnamed image is removed by its full ID", selector: idA,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{ID: idA}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
|
|
},
|
|
{
|
|
name: "ID expands only stored names", selector: idA,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:latest"},
|
|
RepoDigests: []string{"myapp@" + idA, "other@" + idA},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"}},
|
|
},
|
|
{
|
|
name: "ID removes an untagged image by ID even if RepoDigests is populated", selector: idA,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoDigests: []string{digestRef},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: idA}},
|
|
},
|
|
{
|
|
name: "synthesized digest selects repository tags without a digest row", selector: digestRef,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:old", "other:keep", "myapp:latest"},
|
|
RepoDigests: []string{digestRef, "other@" + testImageID("c")},
|
|
}})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
},
|
|
},
|
|
{
|
|
name: "explicit digest lookup works without stored names", selector: digestRef,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoDigests: []string{digestRef},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
|
},
|
|
{
|
|
name: "synthesized digest resolves by target and repository even if not listed", selector: "myapp@" + idA,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{"other@" + idA},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
|
},
|
|
{
|
|
name: "stored digest follows its repository tags", selector: digestRef,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
|
|
testImage(idB, "myapp@"+idB),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "digest pattern also expands matching repository tags", selector: "myapp@sha256:c*",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "myapp:latest", digestRef, "other:keep"),
|
|
testImage(idB, "myapp@"+idB),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "digest pattern matches stored names and ignores derived names", selector: "myapp@sha256:*",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", digestRef),
|
|
{ID: idB, RepoTags: []string{"myapp:latest"}, RepoDigests: []string{"myapp@" + idB}},
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "last repository tag includes its stored digest", selector: "myapp:old",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", digestRef, "other:keep"),
|
|
})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:old"},
|
|
{MachineName: "dev1", ImageID: idA, Image: digestRef},
|
|
},
|
|
},
|
|
{
|
|
name: "last tag does not add a synthesized digest row", selector: "myapp:old",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:old", "other:keep"}, RepoDigests: []string{digestRef},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
|
},
|
|
{
|
|
name: "remaining repository tag preserves its stored digest", selector: "myapp:old",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, "myapp:old", "myapp:latest", digestRef),
|
|
})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: "myapp:old"}},
|
|
},
|
|
{
|
|
name: "digest without repository tags remains executable", selector: digestRef,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(idA, digestRef, "other:keep"),
|
|
})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
|
},
|
|
{
|
|
name: "digest-only image keeps its named reference", selector: idA,
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{digestRef}, RepoDigests: []string{digestRef, testImageID("c")},
|
|
}})},
|
|
want: []removeImageOperation{{MachineName: "dev1", ImageID: idA, Image: digestRef}},
|
|
},
|
|
{
|
|
name: "tag pattern selects only matching tags", selector: "myapp:1.[12]",
|
|
inventory: []*machineImageInventory{testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:1.3", "myapp:1.2", "myapp:1.1"}, RepoDigests: []string{digestRef},
|
|
}})},
|
|
want: []removeImageOperation{
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.1"},
|
|
{MachineName: "dev1", ImageID: idA, Image: "myapp:1.2"},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
plan, err := planImageRemovalFromInventory([]string{tt.selector}, tt.inventory)
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, plan.GroupPlans, 1)
|
|
assert.Equal(t, tt.selector, plan.GroupPlans[0].Image)
|
|
|
|
var got []removeImageOperation
|
|
for _, op := range plan.GroupPlans[0].Operations {
|
|
got = append(got, *op)
|
|
}
|
|
assert.Equal(t, tt.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPlanImageRemovalFromInventory_OverlappingSelectors(t *testing.T) {
|
|
inventory := []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{testImage(testImageID("a"), "myapp:old", "myapp:latest")}),
|
|
testMachineInventory("machine2", []image.Summary{testImage(testImageID("b"), "myapp:test", "myapp:old")}),
|
|
}
|
|
|
|
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp", "myapp:*", testImageID("a"), "myapp:old"},
|
|
inventory)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
|
|
{Image: "myapp:old", Operations: []*removeImageOperation{
|
|
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:old"},
|
|
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:old"},
|
|
}},
|
|
{Image: "myapp", Operations: []*removeImageOperation{
|
|
{MachineName: "dev1", ImageID: testImageID("a"), Image: "myapp:latest"},
|
|
{MachineName: "machine2", ImageID: testImageID("b"), Image: "myapp:test"},
|
|
}},
|
|
}}, plan, "the first selector owns each removal and empty groups are omitted")
|
|
}
|
|
|
|
func TestPlanImageRemovalFromInventory_DigestFollowsLastTagAcrossSelectors(t *testing.T) {
|
|
imageID := testImageID("a")
|
|
digestRef := "myapp@" + imageID
|
|
inventory := []*machineImageInventory{testMachineInventory("dev1", []image.Summary{
|
|
testImage(imageID, "myapp:old", "myapp:latest", "myapp:old", digestRef, digestRef, "other:keep"),
|
|
})}
|
|
|
|
plan, err := planImageRemovalFromInventory([]string{"myapp:old", "myapp:latest", digestRef}, inventory)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, &imageRemovalPlan{GroupPlans: []*removeImageGroupPlan{
|
|
{Image: "myapp:old", Operations: []*removeImageOperation{
|
|
{MachineName: "dev1", ImageID: imageID, Image: "myapp:old"},
|
|
}},
|
|
{Image: "myapp:latest", Operations: []*removeImageOperation{
|
|
{MachineName: "dev1", ImageID: imageID, Image: "myapp:latest"},
|
|
{MachineName: "dev1", ImageID: imageID, Image: digestRef},
|
|
}},
|
|
}}, plan, "digest cleanup belongs to the last tag removal, so the later digest selector has no extra operation")
|
|
}
|
|
|
|
func TestPlanImageRemovalFromInventory_RejectsInvalidOrMissingSelectors(t *testing.T) {
|
|
idA := "sha256:abcdef" + strings.Repeat("a", 58)
|
|
idB := "sha256:abcdef" + strings.Repeat("b", 58)
|
|
inventory := []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{{
|
|
ID: idA, RepoTags: []string{"myapp:old"}, RepoDigests: []string{"myapp@" + idA, "derived@" + idA},
|
|
}}),
|
|
testMachineInventory("machine2", []image.Summary{{ID: idB, RepoTags: []string{"myapp:old"}}}),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
images []string
|
|
err string
|
|
}{
|
|
{"missing repository", []string{"missing"}, "not found"},
|
|
{"repository only has a derived digest", []string{"derived"}, "not found"},
|
|
{"missing tag", []string{"myapp:missing"}, "not found"},
|
|
{"missing pattern", []string{"other:*"}, "not found"},
|
|
{"pattern only matches a derived digest", []string{"myapp@sha256:*"}, "not found"},
|
|
{"missing ID", []string{testImageID("c")}, "not found"},
|
|
{"ambiguous ID prefix", []string{"abcdef"}, "ambiguous"},
|
|
{"invalid name", []string{"MyApp:old"}, "invalid image"},
|
|
{"invalid pattern", []string{"myapp:["}, "invalid image pattern"},
|
|
{"one missing selector rejects the whole plan", []string{"myapp:old", "myapp:missing"}, "not found"},
|
|
{"one invalid selector rejects the whole plan", []string{"myapp:old", "myapp:["}, "invalid image pattern"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
plan, err := planImageRemovalFromInventory(tt.images, inventory)
|
|
require.ErrorContains(t, err, tt.err)
|
|
assert.Nil(t, plan, "a failed selector must not return a partial plan")
|
|
})
|
|
}
|
|
|
|
t.Run("ID prefix resolves within the supplied machines", func(t *testing.T) {
|
|
plan, err := planImageRemovalFromInventory([]string{"abcdef"}, inventory[:1])
|
|
require.NoError(t, err)
|
|
require.Len(t, plan.GroupPlans, 1)
|
|
require.Len(t, plan.GroupPlans[0].Operations, 1)
|
|
assert.Equal(t, idA, plan.GroupPlans[0].Operations[0].ImageID)
|
|
})
|
|
}
|
|
|
|
func TestPlanImageRemovalFromInventory_InUseImagesAreStillPlanned(t *testing.T) {
|
|
img := testImage(testImageID("a"), "myapp:old", "myapp:current")
|
|
img.Containers = 1
|
|
|
|
for _, selector := range []string{"myapp", "myapp:current", img.ID, "myapp:*"} {
|
|
t.Run(selector, func(t *testing.T) {
|
|
plan, err := planImageRemovalFromInventory([]string{selector}, []*machineImageInventory{
|
|
testMachineInventory("dev1", []image.Summary{img}),
|
|
})
|
|
require.NoError(t, err)
|
|
assert.False(t, plan.IsEmpty(), "usage protection belongs to the backend at execution time")
|
|
})
|
|
}
|
|
}
|
|
|
|
func testImageID(char string) string { return "sha256:" + strings.Repeat(char, 64) }
|
|
|
|
func testImage(imageID string, tags ...string) image.Summary {
|
|
return image.Summary{ID: imageID, RepoTags: tags}
|
|
}
|
|
|
|
func testMachineInventory(name string, images []image.Summary) *machineImageInventory {
|
|
return &machineImageInventory{machineName: name, images: images}
|
|
}
|