Skip to content

Commit 01fb0bc

Browse files
committed
Fix(quadlet): Deduplicate removal files for .app expansion (#27653)
Refactor removelist into sets Add quadlet rm --all test with safename add an integration test for quadlet rm --all Signed-off-by: Kavish Gour <kavishgr@protonmail.com>
1 parent 0bd2b4b commit 01fb0bc

3 files changed

Lines changed: 217 additions & 37 deletions

File tree

pkg/domain/infra/abi/quadlet.go

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -760,12 +760,16 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string,
760760
Errors: make(map[string]error),
761761
Removed: []string{},
762762
}
763-
removeList := []string{}
763+
764764
reverseMap, appMap, err := buildAppMap(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()))
765765
if err != nil {
766766
return nil, fmt.Errorf("unable to build app map: %w", err)
767767
}
768-
expandQuadletList := []string{}
768+
769+
// Use sets for deduplication
770+
removeSet := make(map[string]struct{})
771+
expandQuadletSet := make(map[string]struct{})
772+
769773
// Process all `.app` files in arguments, if `.app` file
770774
// is found then expand it to its respective quadlet files
771775
// and remove it from the processing list.
@@ -777,32 +781,36 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string,
777781
if ok {
778782
for _, file := range files {
779783
if !systemdquadlet.IsExtSupported(file) {
780-
removeList = append(removeList, file)
784+
removeSet[file] = struct{}{}
781785
} else {
782-
expandQuadletList = append(expandQuadletList, file)
786+
expandQuadletSet[file] = struct{}{}
783787
}
784788
}
785789
}
786790
// also add .app file itself to the remove list so it can
787791
// be cleaned after removal of all components in the list
788-
if !slices.Contains(removeList, quadlet) {
789-
removeList = append(removeList, quadlet)
790-
}
792+
removeSet[quadlet] = struct{}{}
791793
} else {
792-
expandQuadletList = append(expandQuadletList, quadlet)
794+
expandQuadletSet[quadlet] = struct{}{}
793795
}
794796
}
795-
quadlets = expandQuadletList
797+
798+
// Convert expandQuadletSet to slice
799+
quadlets = make([]string, 0, len(expandQuadletSet))
800+
for quadlet := range expandQuadletSet {
801+
quadlets = append(quadlets, quadlet)
802+
}
803+
804+
if len(quadlets) == 0 && !options.All {
805+
return nil, errors.New("must provide at least 1 quadlet to remove")
806+
}
807+
796808
allQuadletPaths := make([]string, 0, len(quadlets))
797809
allServiceNames := make([]string, 0, len(quadlets))
798810
runningQuadlets := make([]string, 0, len(quadlets))
799811
serviceNameToQuadletName := make(map[string]string)
800812
needReload := options.ReloadSystemd
801813

802-
if len(quadlets) == 0 && !options.All {
803-
return nil, errors.New("must provide at least 1 quadlet to remove")
804-
}
805-
806814
// Is systemd available to the current user?
807815
// We cannot proceed if not.
808816
conn, err := systemd.ConnectToDBUS()
@@ -813,7 +821,12 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string,
813821

814822
if options.All {
815823
allQuadlets := getAllQuadletPaths()
816-
quadlets = allQuadlets
824+
for _, quadlet := range allQuadlets {
825+
if _, exists := expandQuadletSet[quadlet]; !exists {
826+
quadlets = append(quadlets, quadlet)
827+
expandQuadletSet[quadlet] = struct{}{}
828+
}
829+
}
817830
}
818831

819832
// We are using index wise iteration here instead of `range`
@@ -838,30 +851,37 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string,
838851
}
839852
continue
840853
}
841-
value, ok := reverseMap[quadlet]
854+
// Use base filename for reverseMap lookup since map keys are filenames, not full paths
855+
quadletBaseName := filepath.Base(quadlet)
856+
value, ok := reverseMap[quadletBaseName]
842857
if ok {
843858
// If this is part of app and we are cleaning entire .app
844859
// make sure to add .app file itself to the removal list
845860
// if it does not already exists.
846-
if !slices.Contains(removeList, value) {
847-
removeList = append(removeList, value)
848-
}
861+
removeSet[value] = struct{}{}
849862
appFilePath := filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), value)
850863
filesToRemove, err := getAssetListFromFile(appFilePath)
851864
if err != nil {
852865
return nil, fmt.Errorf("unable to get list of files to remove: %w", err)
853866
}
854867
for _, entry := range filesToRemove {
855868
if !systemdquadlet.IsExtSupported(entry) {
856-
removeList = append(removeList, entry)
857-
if !slices.Contains(removeList, value) {
858-
// In the last also clean .<quadlet>.app file
859-
removeList = append(removeList, value)
860-
}
869+
removeSet[entry] = struct{}{}
870+
removeSet[value] = struct{}{}
861871
continue
862872
}
863-
if !slices.Contains(quadlets, entry) {
864-
quadlets = append(quadlets, entry)
873+
// When options.All is true, use full paths; when false, use filenames.
874+
// this ensures consistency since getAllQuadletPaths() returns full paths
875+
// but getQuadletPathByName() expects filenames
876+
var entryToAdd string
877+
if options.All {
878+
entryToAdd = filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), entry)
879+
} else {
880+
entryToAdd = entry
881+
}
882+
if _, exists := expandQuadletSet[entryToAdd]; !exists {
883+
quadlets = append(quadlets, entryToAdd)
884+
expandQuadletSet[entryToAdd] = struct{}{}
865885
}
866886
}
867887
}
@@ -937,13 +957,24 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string,
937957
continue
938958
}
939959
}
940-
for _, entry := range removeList {
941-
os.Remove(filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), entry))
942-
}
943960
report.Removed = append(report.Removed, quadletName)
944961
}
945962
}
946963

964+
// Remove .app and .asset files after the main quadlet removal loop
965+
// This ensures they are cleaned up properly since they are not included in allQuadletPaths
966+
installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())
967+
for entry := range removeSet {
968+
entryPath := filepath.Join(installDir, entry)
969+
if err := os.Remove(entryPath); err != nil {
970+
if !errors.Is(err, fs.ErrNotExist) {
971+
logrus.Warnf("Failed to remove metadata file %s: %v", entry, err)
972+
}
973+
} else {
974+
logrus.Debugf("Removed metadata file %s", entry)
975+
}
976+
}
977+
947978
// Reload systemd, if necessary/requested.
948979
if needReload {
949980
if err := conn.ReloadContext(ctx); err != nil {

test/apiv2/36-quadlets.at

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,35 +20,37 @@ quadlet_build_name="$quadlet_name.build"
2020

2121
TMPDIR=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX)
2222

23-
quadlet_container_file_content=$(cat << EOF
23+
quadlet_container_file_content=$(
24+
cat <<EOF
2425
[Container]
2526
Image=$IMAGE
2627
EOF
2728
)
2829

29-
quadlet_build_file_content=$(cat << EOF
30+
quadlet_build_file_content=$(
31+
cat <<EOF
3032
[Build]
3133
ImageTag=localhost/$quadlet_name
3234
EOF
3335
)
3436

35-
echo "$quadlet_container_file_content" > $TMPDIR/$quadlet_container_name
36-
echo "$quadlet_build_file_content" > $TMPDIR/$quadlet_build_name
37+
echo "$quadlet_container_file_content" >$TMPDIR/$quadlet_container_name
38+
echo "$quadlet_build_file_content" >$TMPDIR/$quadlet_build_name
3739

3840
# this should ensure the .config/containers/systemd directory is created
3941
podman quadlet install $TMPDIR/$quadlet_container_name
4042
podman quadlet install $TMPDIR/$quadlet_build_name
4143

4244
filter_param=$(printf '{"name":["%s"]}' "$quadlet_name")
4345
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
44-
length=2 \
45-
.[0].Name="$quadlet_build_name" \
46-
.[1].Name="$quadlet_container_name"
46+
length=2 \
47+
.[0].Name="$quadlet_build_name" \
48+
.[1].Name="$quadlet_container_name"
4749

4850
filter_param=$(printf '{"name":["%s"]}' "$quadlet_container_name")
4951
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
50-
length=1 \
51-
.[0].Name="$quadlet_container_name"
52+
length=1 \
53+
.[0].Name="$quadlet_container_name"
5254

5355
t GET "libpod/quadlets/$quadlet_name/file" 404
5456

@@ -62,4 +64,105 @@ podman quadlet rm $quadlet_container_name
6264
podman quadlet rm $quadlet_build_name
6365
rm -rf $TMPDIR
6466

67+
# podman quadlet rm -all --force : Testing removal of all quadlets at once
68+
#
69+
# Install quadlets(.container, .network, .volume)
70+
71+
quadlet_name=quadlet-remove-all-test-$(cat /proc/sys/kernel/random/uuid)
72+
73+
quadlet_container="$quadlet_name.container"
74+
75+
quadlet_network="$quadlet_name.network"
76+
77+
quadlet_volume="$quadlet_name.volume"
78+
79+
TMPDIR=$(mktemp -d podman-apiv2-test.quadlet.remove.all.XXXXXXXX)
80+
81+
quadlet_container_file_content=$(
82+
cat <<EOF
83+
[Container]
84+
Image=$IMAGE
85+
EOF
86+
)
87+
88+
quadlet_network_file_content=$(
89+
cat <<EOF
90+
[Network]
91+
Label=app=nginx-network
92+
EOF
93+
)
94+
95+
quadlet_volume_file_content=$(
96+
cat <<EOF
97+
[Volume]
98+
VolumeName=does_not_exist
99+
EOF
100+
)
101+
102+
echo "$quadlet_container_file_content" >$TMPDIR/$quadlet_container
103+
echo "$quadlet_network_file_content" >$TMPDIR/$quadlet_network
104+
echo "$quadlet_volume_file_content" >$TMPDIR/$quadlet_volume
105+
106+
# Install all quadlets
107+
podman quadlet install $TMPDIR
108+
109+
# Verify all quadlets exist via API (file endpoint)
110+
111+
t GET libpod/quadlets/$quadlet_container/file 200
112+
is "$output" "$quadlet_container_file_content"
113+
114+
t GET libpod/quadlets/$quadlet_network/file 200
115+
is "$output" "$quadlet_network_file_content"
116+
117+
t GET libpod/quadlets/$quadlet_volume/file 200
118+
is "$output" "$quadlet_volume_file_content"
119+
120+
# Verify all quadlets appear in the list using filters
121+
122+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_container")
123+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
124+
length=1 \
125+
.[0].Name="$quadlet_container"
126+
127+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_network")
128+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
129+
length=1 \
130+
.[0].Name="$quadlet_network"
131+
132+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_volume")
133+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
134+
length=1 \
135+
.[0].Name="$quadlet_volume"
136+
137+
# Remove ALL quadlets using CLI
138+
podman quadlet rm -af
139+
140+
# Verify all quadlets are gone via API (should return 404)
141+
142+
t GET libpod/quadlets/$quadlet_container/file 404
143+
t GET libpod/quadlets/$quadlet_network/file 404
144+
t GET libpod/quadlets/$quadlet_volume/file 404
145+
146+
# Verify all quadlets no longer appear in filtered lists
147+
148+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_container")
149+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
150+
length=0
151+
152+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_network")
153+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
154+
length=0
155+
156+
filter_param=$(printf '{"name":["%s"]}' "$quadlet_volume")
157+
t GET "libpod/quadlets/json?filters=$filter_param" 200 \
158+
length=0
159+
160+
# Verify the complete list is now empty
161+
162+
t GET libpod/quadlets/json 200 \
163+
length=0
164+
165+
# Cleanup
166+
rm -rf $TMPDIR
167+
65168
# vim: filetype=sh

test/e2e/quadlet_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,4 +1334,50 @@ BOGUS=foo
13341334
},
13351335
),
13361336
)
1337+
1338+
Describe("Running quadlet force remove all", func() {
1339+
It("Should remove all quadlets at once", func() {
1340+
quadletName := fmt.Sprintf("quadlet-remove-all-test-%d", GinkgoRandomSeed())
1341+
containerFile := quadletName + ".container"
1342+
networkFile := quadletName + ".network"
1343+
volumeFile := quadletName + ".volume"
1344+
1345+
tmpDir := filepath.Join(podmanTest.TempDir, quadletName)
1346+
err := os.Mkdir(tmpDir, 0o755)
1347+
Expect(err).ToNot(HaveOccurred())
1348+
1349+
containerContent := []byte(fmt.Sprintf("[Container]\nImage=%s\n", ALPINE))
1350+
networkContent := []byte("[Network]\nLabel=app=nginx-network\n")
1351+
volumeContent := []byte("[Volume]\nVolumeName=does_not_exist\n")
1352+
1353+
Expect(os.WriteFile(filepath.Join(tmpDir, containerFile), containerContent, 0o644)).To(Succeed())
1354+
Expect(os.WriteFile(filepath.Join(tmpDir, networkFile), networkContent, 0o644)).To(Succeed())
1355+
Expect(os.WriteFile(filepath.Join(tmpDir, volumeFile), volumeContent, 0o644)).To(Succeed())
1356+
1357+
installSession := podmanTest.Podman([]string{"quadlet", "install", tmpDir})
1358+
installSession.WaitWithDefaultTimeout()
1359+
Expect(installSession).Should(Exit(0))
1360+
1361+
// Verify quadlets appear in list
1362+
listSession := podmanTest.Podman([]string{"quadlet", "list", "--format", "json"})
1363+
listSession.WaitWithDefaultTimeout()
1364+
Expect(listSession).Should(Exit(0))
1365+
output := listSession.OutputToString()
1366+
Expect(output).To(ContainSubstring(containerFile))
1367+
Expect(output).To(ContainSubstring(networkFile))
1368+
Expect(output).To(ContainSubstring(volumeFile))
1369+
1370+
// Remove all quadlets
1371+
rmSession := podmanTest.Podman([]string{"quadlet", "rm", "-af"})
1372+
rmSession.WaitWithDefaultTimeout()
1373+
Expect(rmSession).Should(Exit(0))
1374+
1375+
// Verify list is empty
1376+
listSession = podmanTest.Podman([]string{"quadlet", "list", "--format", "json"})
1377+
listSession.WaitWithDefaultTimeout()
1378+
Expect(listSession).Should(Exit(0))
1379+
output = listSession.OutputToString()
1380+
Expect(output).To(Equal("[]"))
1381+
})
1382+
})
13371383
})

0 commit comments

Comments
 (0)