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>
This commit is contained in:
flouthoc
2025-11-03 13:00:09 -08:00
parent e787b4f503
commit c22c3271bb
3 changed files with 318 additions and 156 deletions

View File

@@ -16,9 +16,9 @@ This command allows you to:
* Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation ( example a config file for a quadlet container ).
* 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.
* 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.
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.
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.
Note: In case user wants to install Quadlet application then first path should be the path to application directory.
@@ -69,15 +69,11 @@ $ cat webapp.quadlets
Image=nginx:latest
ContainerName=web-server
PublishPort=8080:80
---
# FileName=app-storage
[Volume]
Label=app=webapp
---
# FileName=app-network
[Network]
Subnet=10.0.0.0/24

View File

@@ -164,7 +164,15 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
for _, toInstall := range paths {
validateQuadletFile := false
if assetFile == "" {
assetFile = "." + filepath.Base(toInstall) + ".asset"
// Check if this is a .quadlets file - if so, treat as an app
ext := filepath.Ext(toInstall)
if ext == ".quadlets" {
// For .quadlets files, use .app extension to group all quadlets as one application
baseName := strings.TrimSuffix(filepath.Base(toInstall), filepath.Ext(toInstall))
assetFile = "." + baseName + ".app"
} else {
assetFile = "." + filepath.Base(toInstall) + ".asset"
}
validateQuadletFile = true
}
switch {
@@ -212,36 +220,17 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
// Check if this file has a supported extension or is a .quadlets file
hasValidExt := systemdquadlet.IsExtSupported(toInstall)
ext := strings.ToLower(filepath.Ext(toInstall))
isQuadletsFile := ext == ".quadlets"
// Only check for multi-quadlet content if it's a .quadlets file
var isMulti bool
if isQuadletsFile {
var err error
isMulti, err = isMultiQuadletFile(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err)
continue
}
// For .quadlets files, always treat as multi-quadlet (even single quadlets)
isMulti = true
}
isQuadletsFile := filepath.Ext(toInstall) == ".quadlets"
// Handle files with unsupported extensions that are not .quadlets files
if !hasValidExt && !isQuadletsFile {
// If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
if assetFile != "" {
// This is part of an app installation, allow non-quadlet files as assets
// Don't validate as quadlet file (validateQuadletFile will be false)
} else {
// Standalone files with unsupported extensions are not allowed
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
continue
}
// If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
// Standalone files with unsupported extensions are not allowed
if !hasValidExt && !isQuadletsFile && assetFile == "" {
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
continue
}
if isMulti {
if isQuadletsFile {
// Parse the multi-quadlet file
quadlets, err := parseMultiQuadletFile(toInstall)
if err != nil {
@@ -257,31 +246,25 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err)
continue
}
defer os.Remove(tmpFile.Name())
// Write the quadlet content to the temporary file
_, err = tmpFile.WriteString(quadlet.content)
tmpFile.Close()
if err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
continue
}
tmpFile.Close()
// Install the quadlet from the temporary file
destName := quadlet.name + quadlet.extension
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace)
if err != nil {
os.Remove(tmpFile.Name())
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", quadlet.name, err)
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", destName, err)
continue
}
// Clean up temporary file
os.Remove(tmpFile.Name())
// Record the installation (use a unique key for each section)
sectionKey := fmt.Sprintf("%s#%s", toInstall, quadlet.name)
sectionKey := fmt.Sprintf("%s#%s", toInstall, destName)
installReport.InstalledQuadlets[sectionKey] = installedPath
}
} else {
@@ -385,6 +368,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
if err != nil {
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
}
} else if strings.HasSuffix(assetFile, ".app") {
// For quadlet files that are part of an application (indicated by .app extension),
// also write the quadlet filename to the .app file for proper application tracking
quadletName := filepath.Base(finalPath)
err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName)
if err != nil {
return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err)
}
}
return finalPath, nil
}
@@ -430,11 +421,8 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
currentSection.Reset()
}
} else {
// Add line to current section
if currentSection.Len() > 0 {
currentSection.WriteString("\n")
}
currentSection.WriteString(line)
currentSection.WriteString("\n")
}
}
@@ -443,9 +431,6 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
sections = append(sections, currentSection.String())
}
baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
isMultiSection := len(sections) > 1
// Pre-allocate slice with capacity based on number of sections
quadlets := make([]quadletSection, 0, len(sections))
@@ -462,19 +447,11 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
return nil, fmt.Errorf("unable to detect quadlet type in section %d: %w", i+1, err)
}
// Extract name for this quadlet section
var name string
if isMultiSection {
// For multi-section files, extract FileName from comments
fileName, err := extractFileNameFromSection(section)
if err != nil {
return nil, fmt.Errorf("section %d: %w", i+1, err)
}
name = fileName
} else {
// Single section, use original name
name = baseName
fileName, err := extractFileNameFromSection(section)
if err != nil {
return nil, fmt.Errorf("section %d: %w", i+1, err)
}
name := fileName
quadlets = append(quadlets, quadletSection{
content: section,
@@ -506,13 +483,10 @@ func extractFileNameFromSection(content string) (string, error) {
if fileName == "" {
return "", fmt.Errorf("FileName comment found but no filename specified")
}
// Validate filename (basic validation - no path separators, no extensions)
// Validate filename (basic validation - no path separators)
if strings.ContainsAny(fileName, "/\\") {
return "", fmt.Errorf("FileName '%s' cannot contain path separators", fileName)
}
if strings.Contains(fileName, ".") {
return "", fmt.Errorf("FileName '%s' should not include file extension", fileName)
}
return fileName, nil
}
}
@@ -529,45 +503,15 @@ func detectQuadletType(content string) (string, error) {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
sectionName := strings.ToLower(strings.Trim(line, "[]"))
switch sectionName {
case "container":
return ".container", nil
case "volume":
return ".volume", nil
case "network":
return ".network", nil
case "kube":
return ".kube", nil
case "image":
return ".image", nil
case "build":
return ".build", nil
case "pod":
return ".pod", nil
expected := "." + sectionName
if systemdquadlet.IsExtSupported("a" + expected) {
return expected, nil
}
}
}
return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])")
}
// isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter
// The delimiter must be on its own line (possibly with whitespace)
func isMultiQuadletFile(filePath string) (bool, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return false, err
}
lines := strings.Split(string(content), "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
return true, nil
}
}
return false, nil
}
// buildAppMap scans the given directory for files that start with '.'
// and end with '.app', reads their contents (one filename per line), and
// returns a map where each filename maps to the .app file that contains it.

View File

@@ -15,6 +15,8 @@ function setup() {
}
function teardown() {
# remove any remaining quadlets from tests
run_podman quadlet rm --all -f
systemctl daemon-reload
basic_teardown
}
@@ -35,113 +37,175 @@ function get_quadlet_install_dir() {
# Determine the install directory path based on rootless/root
local install_dir=$(get_quadlet_install_dir)
# Create a multi-quadlet file
local multi_quadlet_file=$PODMAN_TMPDIR/webapp.quadlets
# Generate random names for parallelism
local app_name="webapp_$(random_string)"
local container_name="webserver_$(random_string)"
local volume_name="appstorage_$(random_string)"
local network_name="appnetwork_$(random_string)"
# Create a multi-quadlet file with additional systemd sections
local multi_quadlet_file=$PODMAN_TMPDIR/${app_name}.quadlets
cat > $multi_quadlet_file <<EOF
# FileName=webserver
# FileName=$container_name
# Web application stack
[Unit]
Description=Web server container for application
After=network.target
Wants=network.target
[Container]
Image=$IMAGE
ContainerName=web-server
ContainerName=web-server-$(random_string)
PublishPort=8080:80
Environment=APP_ENV=production
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
---
# FileName=appstorage
# FileName=$volume_name
# Database volume
[Unit]
Description=Database storage volume
Documentation=https://example.com/storage-docs
[Volume]
Label=app=webapp
Label=app=$app_name
Label=component=database
Driver=local
[Install]
WantedBy=multi-user.target
---
# FileName=appnetwork
# FileName=$network_name
# Application network
[Unit]
Description=Application network for web services
[Network]
Subnet=10.0.0.0/24
Gateway=10.0.0.1
Label=app=webapp
Label=app=$app_name
[Install]
WantedBy=multi-user.target
EOF
# Test quadlet install with multi-quadlet file
run_podman quadlet install $multi_quadlet_file
# Verify install output contains all three quadlet names
assert "$output" =~ "webserver.container" "install output should contain webserver.container"
assert "$output" =~ "appstorage.volume" "install output should contain appstorage.volume"
assert "$output" =~ "appnetwork.network" "install output should contain appnetwork.network"
assert "$output" =~ "${container_name}.container" "install output should contain ${container_name}.container"
assert "$output" =~ "${volume_name}.volume" "install output should contain ${volume_name}.volume"
assert "$output" =~ "${network_name}.network" "install output should contain ${network_name}.network"
# Count lines in output (should be 3 lines, one for each quadlet)
assert "${#lines[@]}" -eq 3 "install output should contain exactly three lines"
# Test quadlet list to verify all quadlets were installed
run_podman quadlet list
assert "$output" =~ "webserver.container" "list should contain webserver.container"
assert "$output" =~ "appstorage.volume" "list should contain appstorage.volume"
assert "$output" =~ "appnetwork.network" "list should contain appnetwork.network"
assert "$output" =~ "${container_name}.container" "list should contain ${container_name}.container"
assert "$output" =~ "${volume_name}.volume" "list should contain ${volume_name}.volume"
assert "$output" =~ "${network_name}.network" "list should contain ${network_name}.network"
# Verify the files exist on disk
[[ -f "$install_dir/webserver.container" ]] || die "webserver.container should exist on disk"
[[ -f "$install_dir/appstorage.volume" ]] || die "appstorage.volume should exist on disk"
[[ -f "$install_dir/appnetwork.network" ]] || die "appnetwork.network should exist on disk"
[[ -f "$install_dir/${container_name}.container" ]] || die "${container_name}.container should exist on disk"
[[ -f "$install_dir/${volume_name}.volume" ]] || die "${volume_name}.volume should exist on disk"
[[ -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should exist on disk"
# Verify the content of each installed file
run cat "$install_dir/webserver.container"
assert "$output" =~ "\\[Container\\]" "container file should contain [Container] section"
assert "$output" =~ "Image=$IMAGE" "container file should contain correct image"
assert "$output" =~ "ContainerName=web-server" "container file should contain container name"
run cat "$install_dir/appstorage.volume"
assert "$output" =~ "\\[Volume\\]" "volume file should contain [Volume] section"
assert "$output" =~ "Label=app=webapp" "volume file should contain app label"
assert "$output" =~ "Label=component=database" "volume file should contain component label"
run cat "$install_dir/appnetwork.network"
assert "$output" =~ "\\[Network\\]" "network file should contain [Network] section"
assert "$output" =~ "Subnet=10.0.0.0/24" "network file should contain subnet"
assert "$output" =~ "Gateway=10.0.0.1" "network file should contain gateway"
# Test quadlet print for each installed quadlet
run_podman quadlet print webserver.container
# Test quadlet print for each installed quadlet and verify systemd sections are preserved
run_podman quadlet print ${container_name}.container
assert "$output" =~ "\\[Unit\\]" "print should show Unit section"
assert "$output" =~ "Description=Web server container" "print should show Unit description"
assert "$output" =~ "After=network.target" "print should show After directive"
assert "$output" =~ "Wants=network.target" "print should show Wants directive"
assert "$output" =~ "\\[Container\\]" "print should show container section"
assert "$output" =~ "Image=$IMAGE" "print should show correct image"
assert "$output" =~ "Environment=APP_ENV=production" "print should show environment variable"
assert "$output" =~ "\\[Service\\]" "print should show Service section"
assert "$output" =~ "Restart=always" "print should show Restart directive"
assert "$output" =~ "TimeoutStartSec=900" "print should show TimeoutStartSec directive"
assert "$output" =~ "\\[Install\\]" "print should show Install section"
assert "$output" =~ "WantedBy=multi-user.target" "print should show WantedBy directive"
run_podman quadlet print appstorage.volume
run_podman quadlet print ${volume_name}.volume
assert "$output" =~ "\\[Unit\\]" "print should show Unit section"
assert "$output" =~ "Description=Database storage volume" "print should show Unit description"
assert "$output" =~ "Documentation=https://example.com/storage-docs" "print should show Documentation directive"
assert "$output" =~ "\\[Volume\\]" "print should show volume section"
assert "$output" =~ "Label=app=webapp" "print should show app label"
assert "$output" =~ "Label=app=$app_name" "print should show app label"
assert "$output" =~ "Driver=local" "print should show Driver directive"
assert "$output" =~ "\\[Install\\]" "print should show Install section"
assert "$output" =~ "WantedBy=multi-user.target" "print should show WantedBy directive"
run_podman quadlet print appnetwork.network
run_podman quadlet print ${network_name}.network
assert "$output" =~ "\\[Unit\\]" "print should show Unit section"
assert "$output" =~ "Description=Application network" "print should show Unit description"
assert "$output" =~ "\\[Network\\]" "print should show network section"
assert "$output" =~ "Subnet=10.0.0.0/24" "print should show subnet"
assert "$output" =~ "\\[Install\\]" "print should show Install section"
assert "$output" =~ "WantedBy=multi-user.target" "print should show WantedBy directive"
# Test quadlet rm for one of the quadlets
run_podman quadlet rm webserver.container
assert "$output" =~ "webserver.container" "remove output should contain webserver.container"
# Check that the .app file was created (all quadlets are part of the same application)
[[ -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should exist"
[[ ! -f "$install_dir/.${container_name}.container.asset" ]] || die "individual .asset files should not exist"
[[ ! -f "$install_dir/.${volume_name}.volume.asset" ]] || die "individual .asset files should not exist"
[[ ! -f "$install_dir/.${network_name}.network.asset" ]] || die "individual .asset files should not exist"
# Verify the container quadlet was removed but others remain
# Verify the .app file contains all quadlet names
run cat "$install_dir/.${app_name}.app"
assert "$output" =~ "${container_name}.container" ".app file should contain ${container_name}.container"
assert "$output" =~ "${volume_name}.volume" ".app file should contain ${volume_name}.volume"
assert "$output" =~ "${network_name}.network" ".app file should contain ${network_name}.network"
# Test quadlet list to verify all quadlets show the same app name
run_podman quadlet list
assert "$output" !~ "webserver.container" "list should not contain removed webserver.container"
assert "$output" =~ "appstorage.volume" "list should still contain appstorage.volume"
assert "$output" =~ "appnetwork.network" "list should still contain appnetwork.network"
local webserver_line=$(echo "$output" | grep "${container_name}.container")
local appstorage_line=$(echo "$output" | grep "${volume_name}.volume")
local appnetwork_line=$(echo "$output" | grep "${network_name}.network")
# Clean up remaining quadlets
run_podman quadlet rm appstorage.volume appnetwork.network
# All lines should contain the same app name (.${app_name}.app)
assert "$webserver_line" =~ "\\.${app_name}\\.app" "${container_name} should show .${app_name}.app as app"
assert "$appstorage_line" =~ "\\.${app_name}\\.app" "${volume_name} should show .${app_name}.app as app"
assert "$appnetwork_line" =~ "\\.${app_name}\\.app" "${network_name} should show .${app_name}.app as app"
# Test quadlet rm for one of the quadlets - should remove entire application
run_podman quadlet rm ${container_name}.container
assert "$output" =~ "${container_name}.container" "remove output should contain ${container_name}.container"
# Verify all quadlets were removed since they're part of the same app
run_podman quadlet list
assert "$output" !~ "${container_name}.container" "list should not contain removed ${container_name}.container"
assert "$output" !~ "${volume_name}.volume" "list should not contain ${volume_name}.volume as app is removed"
assert "$output" !~ "${network_name}.network" "list should not contain ${network_name}.network as app is removed"
# The .app file should also be removed
[[ ! -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should be removed"
}
@test "quadlet verb - install multi-quadlet file with empty sections" {
# Test handling of empty sections between separators
local multi_quadlet_file=$PODMAN_TMPDIR/with-empty.quadlets
local container_name="testcontainer_$(random_string)"
local volume_name="testvolume_$(random_string)"
local multi_quadlet_file=$PODMAN_TMPDIR/with-empty_$(random_string).quadlets
cat > $multi_quadlet_file <<EOF
# FileName=testcontainer
# FileName=$container_name
[Container]
Image=$IMAGE
ContainerName=test-container
ContainerName=test-container-$(random_string)
---
---
# FileName=testvolume
# FileName=$volume_name
[Volume]
Label=test=value
@@ -153,25 +217,26 @@ EOF
run_podman quadlet install $multi_quadlet_file
# Should only install 2 quadlets (empty sections should be skipped)
assert "$output" =~ "testcontainer.container" "install output should contain testcontainer.container"
assert "$output" =~ "testvolume.volume" "install output should contain testvolume.volume"
assert "$output" =~ "${container_name}.container" "install output should contain ${container_name}.container"
assert "$output" =~ "${volume_name}.volume" "install output should contain ${volume_name}.volume"
assert "${#lines[@]}" -eq 2 "install output should contain exactly two lines"
# Clean up
run_podman quadlet rm testcontainer.container testvolume.volume
run_podman quadlet rm ${container_name}.container ${volume_name}.volume
}
@test "quadlet verb - install multi-quadlet file missing FileName" {
# Test error handling when FileName is missing in multi-quadlet file
local multi_quadlet_file=$PODMAN_TMPDIR/missing-filename.quadlets
local volume_name="testvolume_$(random_string)"
local multi_quadlet_file=$PODMAN_TMPDIR/missing-filename_$(random_string).quadlets
cat > $multi_quadlet_file <<EOF
[Container]
Image=$IMAGE
ContainerName=test-container
ContainerName=test-container-$(random_string)
---
# FileName=testvolume
# FileName=$volume_name
[Volume]
Label=test=value
EOF
@@ -180,3 +245,160 @@ EOF
run_podman 125 quadlet install $multi_quadlet_file
assert "$output" =~ "missing required.*FileName" "error should mention missing FileName"
}
@test "quadlet verb - install single-section .quadlets file missing FileName" {
# Test error handling when FileName is missing in a .quadlets file with only one section
local multi_quadlet_file=$PODMAN_TMPDIR/single-missing-filename_$(random_string).quadlets
cat > $multi_quadlet_file <<EOF
[Container]
Image=$IMAGE
ContainerName=test-container-$(random_string)
EOF
# Test quadlet install should fail
run_podman 125 quadlet install $multi_quadlet_file
assert "$output" =~ "missing required.*FileName" "error should mention missing FileName"
}
@test "quadlet verb - install directory with mixed individual and .quadlets files" {
# Test installing from a directory containing both individual quadlet files and .quadlets files
local install_dir=$(get_quadlet_install_dir)
local app_name="mixed-app_$(random_string)"
local app_dir=$PODMAN_TMPDIR/$app_name
mkdir -p "$app_dir"
# Generate random names for all components
local frontend_name="frontend_$(random_string)"
local data_name="data_$(random_string)"
local api_name="api-server_$(random_string)"
local cache_name="cache_$(random_string)"
local network_name="app-network_$(random_string)"
# Create an individual container quadlet file
cat > "$app_dir/${frontend_name}.container" <<EOF
[Container]
Image=$IMAGE
ContainerName=frontend-app-$(random_string)
PublishPort=3000:3000
EOF
# Create an individual volume quadlet file
cat > "$app_dir/${data_name}.volume" <<EOF
[Volume]
Label=app=$app_name
Label=component=storage
EOF
# Create a .quadlets file with multiple quadlets
cat > "$app_dir/backend_$(random_string).quadlets" <<EOF
# FileName=$api_name
[Container]
Image=$IMAGE
ContainerName=api-server-$(random_string)
PublishPort=8080:8080
---
# FileName=$cache_name
[Volume]
Label=app=$app_name
Label=component=cache
---
# FileName=$network_name
[Network]
Subnet=192.168.1.0/24
Gateway=192.168.1.1
Label=app=$app_name
EOF
# Create a non-quadlet asset file (config file)
cat > "$app_dir/app.conf" <<EOF
# Application configuration
debug=true
port=3000
EOF
# Install the directory
run_podman quadlet install "$app_dir"
# Verify all quadlets were installed (2 individual + 3 from .quadlets file = 5 total)
assert "$output" =~ "${frontend_name}.container" "install output should contain ${frontend_name}.container"
assert "$output" =~ "${data_name}.volume" "install output should contain ${data_name}.volume"
assert "$output" =~ "${api_name}.container" "install output should contain ${api_name}.container"
assert "$output" =~ "${cache_name}.volume" "install output should contain ${cache_name}.volume"
assert "$output" =~ "${network_name}.network" "install output should contain ${network_name}.network"
# Count lines in output (should be 6 lines: 5 quadlets + 1 asset file)
assert "${#lines[@]}" -eq 6 "install output should contain exactly six lines"
# Verify all files exist on disk
[[ -f "$install_dir/${frontend_name}.container" ]] || die "${frontend_name}.container should exist on disk"
[[ -f "$install_dir/${data_name}.volume" ]] || die "${data_name}.volume should exist on disk"
[[ -f "$install_dir/${api_name}.container" ]] || die "${api_name}.container should exist on disk"
[[ -f "$install_dir/${cache_name}.volume" ]] || die "${cache_name}.volume should exist on disk"
[[ -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should exist on disk"
[[ -f "$install_dir/app.conf" ]] || die "app.conf should exist on disk"
# Check that the .app file was created (all files are part of one application)
[[ -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should exist"
# Verify the .app file contains all quadlet names
run cat "$install_dir/.${app_name}.app"
assert "$output" =~ "${frontend_name}.container" ".app file should contain ${frontend_name}.container"
assert "$output" =~ "${data_name}.volume" ".app file should contain ${data_name}.volume"
assert "$output" =~ "${api_name}.container" ".app file should contain ${api_name}.container"
assert "$output" =~ "${cache_name}.volume" ".app file should contain ${cache_name}.volume"
assert "$output" =~ "${network_name}.network" ".app file should contain ${network_name}.network"
# Test quadlet list to verify all quadlets show the same app name
run_podman quadlet list
local frontend_line=$(echo "$output" | grep "${frontend_name}.container")
local data_line=$(echo "$output" | grep "${data_name}.volume")
local api_line=$(echo "$output" | grep "${api_name}.container")
local cache_line=$(echo "$output" | grep "${cache_name}.volume")
local network_line=$(echo "$output" | grep "${network_name}.network")
# All lines should contain the same app name (.${app_name}.app)
assert "$frontend_line" =~ "\\.${app_name}\\.app" "${frontend_name} should show .${app_name}.app as app"
assert "$data_line" =~ "\\.${app_name}\\.app" "${data_name} should show .${app_name}.app as app"
assert "$api_line" =~ "\\.${app_name}\\.app" "${api_name} should show .${app_name}.app as app"
assert "$cache_line" =~ "\\.${app_name}\\.app" "${cache_name} should show .${app_name}.app as app"
assert "$network_line" =~ "\\.${app_name}\\.app" "${network_name} should show .${app_name}.app as app"
# Verify content of individual quadlet files
run cat "$install_dir/${frontend_name}.container"
assert "$output" =~ "\\[Container\\]" "frontend container file should contain [Container] section"
assert "$output" =~ "ContainerName=frontend-app-" "frontend container file should contain correct name prefix"
run cat "$install_dir/${api_name}.container"
assert "$output" =~ "\\[Container\\]" "api-server container file should contain [Container] section"
assert "$output" =~ "ContainerName=api-server-" "api-server container file should contain correct name prefix"
run cat "$install_dir/${network_name}.network"
assert "$output" =~ "\\[Network\\]" "network file should contain [Network] section"
assert "$output" =~ "Subnet=192.168.1.0/24" "network file should contain correct subnet"
# Test that removing one quadlet removes the entire application
run_podman quadlet rm ${frontend_name}.container
# All quadlets should be removed since they're part of the same app
run_podman quadlet list
assert "$output" !~ "${frontend_name}.container" "${frontend_name}.container should be removed"
assert "$output" !~ "${data_name}.volume" "${data_name}.volume should also be removed as part of same app"
assert "$output" !~ "${api_name}.container" "${api_name}.container should also be removed as part of same app"
assert "$output" !~ "${cache_name}.volume" "${cache_name}.volume should also be removed as part of same app"
assert "$output" !~ "${network_name}.network" "${network_name}.network should also be removed as part of same app"
# The .app file should also be removed
[[ ! -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should be removed"
# All individual files should be removed
[[ ! -f "$install_dir/${frontend_name}.container" ]] || die "${frontend_name}.container should be removed"
[[ ! -f "$install_dir/${data_name}.volume" ]] || die "${data_name}.volume should be removed"
[[ ! -f "$install_dir/${api_name}.container" ]] || die "${api_name}.container should be removed"
[[ ! -f "$install_dir/${cache_name}.volume" ]] || die "${cache_name}.volume should be removed"
[[ ! -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should be removed"
[[ ! -f "$install_dir/app.conf" ]] || die "app.conf should be removed"
}