Skip to content

Commit c5a0182

Browse files
committed
quadlet install: multiple quadlets from single file should share app
Quadlets installed from `.quadlet` file now belongs to a single application, anyone file removed from this application removes all the other files as well. Assited by: claude-4-sonnet Signed-off-by: flouthoc <flouthoc.git@gmail.com>
1 parent e787b4f commit c5a0182

File tree

3 files changed

+214
-40
lines changed

3 files changed

+214
-40
lines changed

docs/source/markdown/podman-quadlet-install.1.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@ This command allows you to:
1616

1717
* Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation ( example a config file for a quadlet container ).
1818

19-
* Install multiple Quadlets from a single file with `.quadlets` extension where each Quadlet is separated by a `---` delimiter. When using multiple quadlets in a single file, each quadlet section must include a `# FileName=<name>` comment to specify the name for that quadlet.
19+
* Install multiple Quadlets from a single file with the `.quadlets` extension, where each Quadlet is separated by a `---` delimiter. When using multiple quadlets in a single `.quadlets` file, each quadlet section must include a `# FileName=<name>` comment to specify the name for that quadlet.
2020

21-
Note: If a quadlet is part of an application, removing that specific quadlet will remove the entire application. When a quadlet is installed from a directory, all files installed from that directory—including both quadlet and non-quadlet files—are considered part of a single application.
21+
Note: If a quadlet is part of an application, removing that specific quadlet will remove the entire application. When a quadlet is installed from a directory, all files installed from that directory—including both quadlet and non-quadlet files—are considered part of a single application. Similarly, when multiple quadlets are installed from a single `.quadlets` file, they are all considered part of the same application.
2222

2323
Note: In case user wants to install Quadlet application then first path should be the path to application directory.
2424

pkg/domain/infra/abi/quadlet.go

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,15 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
164164
for _, toInstall := range paths {
165165
validateQuadletFile := false
166166
if assetFile == "" {
167-
assetFile = "." + filepath.Base(toInstall) + ".asset"
167+
// Check if this is a .quadlets file - if so, treat as an app
168+
ext := strings.ToLower(filepath.Ext(toInstall))
169+
if ext == ".quadlets" {
170+
// For .quadlets files, use .app extension to group all quadlets as one application
171+
baseName := strings.TrimSuffix(filepath.Base(toInstall), filepath.Ext(toInstall))
172+
assetFile = "." + baseName + ".app"
173+
} else {
174+
assetFile = "." + filepath.Base(toInstall) + ".asset"
175+
}
168176
validateQuadletFile = true
169177
}
170178
switch {
@@ -215,19 +223,6 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
215223
ext := strings.ToLower(filepath.Ext(toInstall))
216224
isQuadletsFile := ext == ".quadlets"
217225

218-
// Only check for multi-quadlet content if it's a .quadlets file
219-
var isMulti bool
220-
if isQuadletsFile {
221-
var err error
222-
isMulti, err = isMultiQuadletFile(toInstall)
223-
if err != nil {
224-
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err)
225-
continue
226-
}
227-
// For .quadlets files, always treat as multi-quadlet (even single quadlets)
228-
isMulti = true
229-
}
230-
231226
// Handle files with unsupported extensions that are not .quadlets files
232227
if !hasValidExt && !isQuadletsFile {
233228
// If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
@@ -241,7 +236,7 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
241236
}
242237
}
243238

244-
if isMulti {
239+
if isQuadletsFile {
245240
// Parse the multi-quadlet file
246241
quadlets, err := parseMultiQuadletFile(toInstall)
247242
if err != nil {
@@ -385,6 +380,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
385380
if err != nil {
386381
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
387382
}
383+
} else if strings.HasSuffix(assetFile, ".app") {
384+
// For quadlet files that are part of an application (indicated by .app extension),
385+
// also write the quadlet filename to the .app file for proper application tracking
386+
quadletName := filepath.Base(finalPath)
387+
err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName)
388+
if err != nil {
389+
return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err)
390+
}
388391
}
389392
return finalPath, nil
390393
}
@@ -550,24 +553,6 @@ func detectQuadletType(content string) (string, error) {
550553
return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])")
551554
}
552555

553-
// isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter
554-
// The delimiter must be on its own line (possibly with whitespace)
555-
func isMultiQuadletFile(filePath string) (bool, error) {
556-
content, err := os.ReadFile(filePath)
557-
if err != nil {
558-
return false, err
559-
}
560-
561-
lines := strings.Split(string(content), "\n")
562-
for _, line := range lines {
563-
trimmed := strings.TrimSpace(line)
564-
if trimmed == "---" {
565-
return true, nil
566-
}
567-
}
568-
return false, nil
569-
}
570-
571556
// buildAppMap scans the given directory for files that start with '.'
572557
// and end with '.app', reads their contents (one filename per line), and
573558
// returns a map where each filename maps to the .app file that contains it.

test/system/254-podman-quadlet-multi.bats

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,8 @@ EOF
121121
# Verify the container quadlet was removed but others remain
122122
run_podman quadlet list
123123
assert "$output" !~ "webserver.container" "list should not contain removed webserver.container"
124-
assert "$output" =~ "appstorage.volume" "list should still contain appstorage.volume"
125-
assert "$output" =~ "appnetwork.network" "list should still contain appnetwork.network"
126-
127-
# Clean up remaining quadlets
128-
run_podman quadlet rm appstorage.volume appnetwork.network
124+
assert "$output" !~ "appstorage.volume" "list should not contain appstorage.volume as app is removed"
125+
assert "$output" !~ "appnetwork.network" "list should not contain appnetwork.network as app is removed"
129126
}
130127

131128
@test "quadlet verb - install multi-quadlet file with empty sections" {
@@ -180,3 +177,195 @@ EOF
180177
run_podman 125 quadlet install $multi_quadlet_file
181178
assert "$output" =~ "missing required.*FileName" "error should mention missing FileName"
182179
}
180+
181+
@test "quadlet verb - multi-quadlet file creates application" {
182+
# Test that quadlets from a .quadlets file are treated as part of the same application
183+
local install_dir=$(get_quadlet_install_dir)
184+
local multi_quadlet_file=$PODMAN_TMPDIR/myapp.quadlets
185+
186+
cat > $multi_quadlet_file <<EOF
187+
# FileName=webapp
188+
[Container]
189+
Image=$IMAGE
190+
ContainerName=webapp
191+
PublishPort=8080:80
192+
193+
---
194+
195+
# FileName=webdb
196+
[Volume]
197+
Label=app=myapp
198+
EOF
199+
200+
# Install the multi-quadlet file
201+
run_podman quadlet install $multi_quadlet_file
202+
203+
# Verify both quadlets were installed
204+
assert "$output" =~ "webapp.container" "install output should contain webapp.container"
205+
assert "$output" =~ "webdb.volume" "install output should contain webdb.volume"
206+
207+
# Check that the .app file was created (not individual .asset files)
208+
[[ -f "$install_dir/.myapp.app" ]] || die ".myapp.app file should exist"
209+
[[ ! -f "$install_dir/.webapp.container.asset" ]] || die "individual .asset files should not exist"
210+
[[ ! -f "$install_dir/.webdb.volume.asset" ]] || die "individual .asset files should not exist"
211+
212+
# Verify the .app file contains both quadlet names
213+
run cat "$install_dir/.myapp.app"
214+
assert "$output" =~ "webapp.container" ".app file should contain webapp.container"
215+
assert "$output" =~ "webdb.volume" ".app file should contain webdb.volume"
216+
217+
# Test quadlet list to verify both quadlets show the same app name
218+
run_podman quadlet list
219+
local webapp_line=$(echo "$output" | grep "webapp.container")
220+
local webdb_line=$(echo "$output" | grep "webdb.volume")
221+
222+
# Both lines should contain the same app name (.myapp.app)
223+
assert "$webapp_line" =~ "\\.myapp\\.app" "webapp should show .myapp.app as app"
224+
assert "$webdb_line" =~ "\\.myapp\\.app" "webdb should show .myapp.app as app"
225+
226+
# Test removing the application by removing one quadlet should remove both
227+
run_podman quadlet rm webapp.container
228+
229+
# Both quadlets should be removed since they're part of the same app
230+
run_podman quadlet list
231+
assert "$output" !~ "webapp.container" "webapp.container should be removed"
232+
assert "$output" !~ "webdb.volume" "webdb.volume should also be removed as part of same app"
233+
234+
# The .app file should also be removed
235+
[[ ! -f "$install_dir/.myapp.app" ]] || die ".myapp.app file should be removed"
236+
}
237+
238+
@test "quadlet verb - install directory with mixed individual and .quadlets files" {
239+
# Test installing from a directory containing both individual quadlet files and .quadlets files
240+
local install_dir=$(get_quadlet_install_dir)
241+
local app_dir=$PODMAN_TMPDIR/mixed-app
242+
mkdir -p "$app_dir"
243+
244+
# Create an individual container quadlet file
245+
cat > "$app_dir/frontend.container" <<EOF
246+
[Container]
247+
Image=$IMAGE
248+
ContainerName=frontend-app
249+
PublishPort=3000:3000
250+
EOF
251+
252+
# Create an individual volume quadlet file
253+
cat > "$app_dir/data.volume" <<EOF
254+
[Volume]
255+
Label=app=mixed-app
256+
Label=component=storage
257+
EOF
258+
259+
# Create a .quadlets file with multiple quadlets
260+
cat > "$app_dir/backend.quadlets" <<EOF
261+
# FileName=api-server
262+
[Container]
263+
Image=$IMAGE
264+
ContainerName=api-server
265+
PublishPort=8080:8080
266+
267+
---
268+
269+
# FileName=cache
270+
[Volume]
271+
Label=app=mixed-app
272+
Label=component=cache
273+
274+
---
275+
276+
# FileName=app-network
277+
[Network]
278+
Subnet=192.168.1.0/24
279+
Gateway=192.168.1.1
280+
Label=app=mixed-app
281+
EOF
282+
283+
# Create a non-quadlet asset file (config file)
284+
cat > "$app_dir/app.conf" <<EOF
285+
# Application configuration
286+
debug=true
287+
port=3000
288+
EOF
289+
290+
# Install the directory
291+
run_podman quadlet install "$app_dir"
292+
293+
# Verify all quadlets were installed (2 individual + 3 from .quadlets file = 5 total)
294+
assert "$output" =~ "frontend.container" "install output should contain frontend.container"
295+
assert "$output" =~ "data.volume" "install output should contain data.volume"
296+
assert "$output" =~ "api-server.container" "install output should contain api-server.container"
297+
assert "$output" =~ "cache.volume" "install output should contain cache.volume"
298+
assert "$output" =~ "app-network.network" "install output should contain app-network.network"
299+
300+
# Count lines in output (should be 6 lines: 5 quadlets + 1 asset file)
301+
assert "${#lines[@]}" -eq 6 "install output should contain exactly six lines"
302+
303+
# Verify all files exist on disk
304+
[[ -f "$install_dir/frontend.container" ]] || die "frontend.container should exist on disk"
305+
[[ -f "$install_dir/data.volume" ]] || die "data.volume should exist on disk"
306+
[[ -f "$install_dir/api-server.container" ]] || die "api-server.container should exist on disk"
307+
[[ -f "$install_dir/cache.volume" ]] || die "cache.volume should exist on disk"
308+
[[ -f "$install_dir/app-network.network" ]] || die "app-network.network should exist on disk"
309+
[[ -f "$install_dir/app.conf" ]] || die "app.conf should exist on disk"
310+
311+
# Check that the .app file was created (all files are part of one application)
312+
[[ -f "$install_dir/.mixed-app.app" ]] || die ".mixed-app.app file should exist"
313+
314+
# Verify the .app file contains all quadlet names
315+
run cat "$install_dir/.mixed-app.app"
316+
assert "$output" =~ "frontend.container" ".app file should contain frontend.container"
317+
assert "$output" =~ "data.volume" ".app file should contain data.volume"
318+
assert "$output" =~ "api-server.container" ".app file should contain api-server.container"
319+
assert "$output" =~ "cache.volume" ".app file should contain cache.volume"
320+
assert "$output" =~ "app-network.network" ".app file should contain app-network.network"
321+
322+
# Test quadlet list to verify all quadlets show the same app name
323+
run_podman quadlet list
324+
local frontend_line=$(echo "$output" | grep "frontend.container")
325+
local data_line=$(echo "$output" | grep "data.volume")
326+
local api_line=$(echo "$output" | grep "api-server.container")
327+
local cache_line=$(echo "$output" | grep "cache.volume")
328+
local network_line=$(echo "$output" | grep "app-network.network")
329+
330+
# All lines should contain the same app name (.mixed-app.app)
331+
assert "$frontend_line" =~ "\\.mixed-app\\.app" "frontend should show .mixed-app.app as app"
332+
assert "$data_line" =~ "\\.mixed-app\\.app" "data should show .mixed-app.app as app"
333+
assert "$api_line" =~ "\\.mixed-app\\.app" "api-server should show .mixed-app.app as app"
334+
assert "$cache_line" =~ "\\.mixed-app\\.app" "cache should show .mixed-app.app as app"
335+
assert "$network_line" =~ "\\.mixed-app\\.app" "app-network should show .mixed-app.app as app"
336+
337+
# Verify content of individual quadlet files
338+
run cat "$install_dir/frontend.container"
339+
assert "$output" =~ "\\[Container\\]" "frontend container file should contain [Container] section"
340+
assert "$output" =~ "ContainerName=frontend-app" "frontend container file should contain correct name"
341+
342+
run cat "$install_dir/api-server.container"
343+
assert "$output" =~ "\\[Container\\]" "api-server container file should contain [Container] section"
344+
assert "$output" =~ "ContainerName=api-server" "api-server container file should contain correct name"
345+
346+
run cat "$install_dir/app-network.network"
347+
assert "$output" =~ "\\[Network\\]" "network file should contain [Network] section"
348+
assert "$output" =~ "Subnet=192.168.1.0/24" "network file should contain correct subnet"
349+
350+
# Test that removing one quadlet removes the entire application
351+
run_podman quadlet rm frontend.container
352+
353+
# All quadlets should be removed since they're part of the same app
354+
run_podman quadlet list
355+
assert "$output" !~ "frontend.container" "frontend.container should be removed"
356+
assert "$output" !~ "data.volume" "data.volume should also be removed as part of same app"
357+
assert "$output" !~ "api-server.container" "api-server.container should also be removed as part of same app"
358+
assert "$output" !~ "cache.volume" "cache.volume should also be removed as part of same app"
359+
assert "$output" !~ "app-network.network" "app-network.network should also be removed as part of same app"
360+
361+
# The .app file should also be removed
362+
[[ ! -f "$install_dir/.mixed-app.app" ]] || die ".mixed-app.app file should be removed"
363+
364+
# All individual files should be removed
365+
[[ ! -f "$install_dir/frontend.container" ]] || die "frontend.container should be removed"
366+
[[ ! -f "$install_dir/data.volume" ]] || die "data.volume should be removed"
367+
[[ ! -f "$install_dir/api-server.container" ]] || die "api-server.container should be removed"
368+
[[ ! -f "$install_dir/cache.volume" ]] || die "cache.volume should be removed"
369+
[[ ! -f "$install_dir/app-network.network" ]] || die "app-network.network should be removed"
370+
[[ ! -f "$install_dir/app.conf" ]] || die "app.conf should be removed"
371+
}

0 commit comments

Comments
 (0)