Quadlet installation code refactoring

- Cleanup the code to install quadlets
- Fix `podman quadlet install` output message (see https://github.com/podman-container-tools/podman/pull/28335#discussion_r3310372000)
- Update libpod quadlet endpoint documentation

Signed-off-by: Mario Loriedo <mario.loriedo@gmail.com>
This commit is contained in:
Mario Loriedo 2026-06-04 14:49:10 +00:00
parent 5cc79ac325
commit d1e2692069
5 changed files with 249 additions and 173 deletions

View file

@ -84,8 +84,9 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error {
// summary: Install quadlet files
// description: |
// Install one or more files for a quadlet application. Each request should contain a single quadlet file
// and optionally more files such as containerfile, kube yaml or configuration files. Supports both tar
// archives and multipart form data uploads.
// and optionally more files such as containerfile, kube yaml or configuration files. When additional
// files are passed, the application query parameter should be specified. Supports both tar archives and
// multipart form data uploads.
// consumes:
// - application/x-tar
// - multipart/form-data
@ -93,6 +94,12 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error {
// - application/json
// parameters:
// - in: query
// name: application
// type: string
// description: |
// Group quadlet and associated files in a directory with the application name.
// Required when additional files are passed.
// - in: query
// name: replace
// type: boolean
// default: false

View file

@ -27,19 +27,23 @@ import (
// Install one or more Quadlet files
func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []string, options entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) {
// Is systemd available to the current user?
// We cannot proceed if not.
// Fail if quadlet files list is empty
if len(pathsOrURLs) == 0 {
return nil, fmt.Errorf("at least one quadlet file path needed")
}
// Fail if systemd isn't available to the current user
conn, err := systemd.ConnectToDBUS()
if err != nil {
return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
}
defer conn.Close()
// Fail if the Quadlet binary cannot be found
cfg, err := config.Default()
if err != nil {
return nil, fmt.Errorf("unable to load default config: %w", err)
}
// Is Quadlet installed? No point if not.
quadletPath, err := cfg.FindHelperBinary("quadlet", true)
if err != nil {
return nil, fmt.Errorf("cannot stat Quadlet generator, Quadlet may not be installed: %w", err)
@ -51,157 +55,129 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
if err != nil {
return nil, fmt.Errorf("cannot stat Quadlet generator, Quadlet may not be installed: %w", err)
}
if !quadletStat.Mode().IsRegular() || quadletStat.Mode()&0o100 == 0 {
return nil, fmt.Errorf("no valid Quadlet binary installed to %q, unable to use Quadlet", quadletPath)
}
// Set installDir (quadlets target directory)
installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())
if len(options.Application) > 0 {
// Prevent path traversal by validating the user input "Application"
err := validateApplicationName(installDir, options.Application)
if err != nil {
return nil, fmt.Errorf("invalid application name: %w", err)
}
installDir = filepath.Join(installDir, options.Application)
}
logrus.Debugf("Going to install Quadlet to directory %s", installDir)
if err := os.MkdirAll(installDir, 0o755); err != nil {
return nil, fmt.Errorf("unable to create Quadlet install path %s: %w", installDir, err)
}
type qpaths struct {
src string
dst string
}
var quadletPaths []qpaths
var quadletURLs, nestedQuadletPaths []string
firstArg := pathsOrURLs[0]
switch {
case isFolder(firstArg):
if options.Application == "" {
return nil, fmt.Errorf("application name cannot be empty when installing from directory")
}
nestedQuadletPaths, err = findNestedQuadlets(firstArg)
if err != nil {
return nil, fmt.Errorf("failed finding quadlet files in folder %q: %w", firstArg, err)
}
case isURL(firstArg):
quadletURLs = append(quadletURLs, firstArg)
default:
quadletPaths = append(quadletPaths, qpaths{firstArg, filepath.Join(installDir, filepath.Base(firstArg))})
}
otherArgs := pathsOrURLs[1:]
for _, pathOrURL := range otherArgs {
if isURL(pathOrURL) {
quadletURLs = append(quadletURLs, pathOrURL)
} else {
quadletPaths = append(quadletPaths, qpaths{pathOrURL, filepath.Join(installDir, filepath.Base(pathOrURL))})
}
}
// Process the quadlet lists
installReport := entities.QuadletInstallReport{
InstalledQuadlets: make(map[string]string),
QuadletErrors: make(map[string]error),
}
paths := pathsOrURLs
if len(pathsOrURLs) > 0 && !strings.HasPrefix(pathsOrURLs[0], "http://") && !strings.HasPrefix(pathsOrURLs[0], "https://") {
// Check if first path is dir, this is an APP
info, err := os.Stat(pathsOrURLs[0])
for _, nestedPath := range nestedQuadletPaths {
// `nestedQuadletPaths` are files under folder
// `firstArg` or one of its subfolders. These files
// need to be installed under folder `installDir`.
// For example file `firstArg + "foo/bar"` needs
// to be installed in `installDir + "foo/bar".
// For this reason we need to get the relative
// path ("foo/bar") and pass it to `installQuadlet`
nestedPathRel, err := filepath.Rel(firstArg, nestedPath)
if err != nil {
return nil, fmt.Errorf("unable to stat Quadlet path %s: %w", pathsOrURLs[0], err)
}
if info.IsDir() {
if len(options.Application) == 0 {
return nil, fmt.Errorf("application name cannot be empty when installing from directory")
}
// If it's a directory, then read all files and add it to paths
entries, err := os.ReadDir(pathsOrURLs[0])
if err != nil {
return nil, fmt.Errorf("unable to read Quadlet dir %s: %w", pathsOrURLs[0], err)
}
redoPaths := make([]string, 0, len(entries))
for _, entry := range entries {
redoPaths = append(redoPaths, filepath.Join(pathsOrURLs[0], entry.Name()))
}
redoPaths = append(redoPaths, pathsOrURLs[1:]...)
paths = redoPaths
} else if !systemdquadlet.IsExtSupported(pathsOrURLs[0]) &&
filepath.Ext(pathsOrURLs[0]) != ".quadlets" {
return nil, fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(pathsOrURLs[0]))
installReport.QuadletErrors[nestedPath] = err
continue
}
quadletPaths = append(quadletPaths, qpaths{nestedPath, filepath.Join(installDir, nestedPathRel)})
}
// Loop over all given URLs
for _, toInstall := range paths {
// Loop over the URLs
for _, quadletURL := range quadletURLs {
installedPath, err := installQuadletFromURL(ctx, ic, quadletURL, installDir, options.Replace)
if err != nil {
installReport.QuadletErrors[quadletURL] = err
continue
}
installReport.InstalledQuadlets[quadletURL] = installedPath
}
// Loop over the paths
for _, quadletPath := range quadletPaths {
err = fileutils.Exists(quadletPath.src)
if err != nil {
installReport.QuadletErrors[quadletPath.src] = err
continue
}
quadletExt := filepath.Ext(quadletPath.src)
// Check if this file is a .quadlets file
switch {
case strings.HasPrefix(toInstall, "http://") || strings.HasPrefix(toInstall, "https://"):
r, err := http.Get(toInstall)
case quadletExt == ".quadlets":
// Parse the multi-quadlet file
sections, err := parseMultiQuadletFile(quadletPath.src)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to download URL %s: %w", toInstall, err)
installReport.QuadletErrors[quadletPath.src] = err
continue
}
defer r.Body.Close()
quadletFileName, err := getFileName(r, toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to get file name from url %s: %w", toInstall, err)
continue
}
// It's a URL. Pull to temporary file.
tmpFile, err := os.CreateTemp("", quadletFileName)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file to download URL %s: %w", toInstall, err)
continue
}
defer func() {
tmpFile.Close()
if err := os.Remove(tmpFile.Name()); err != nil {
logrus.Errorf("unable to remove temporary file %q: %v", tmpFile.Name(), err)
// The sections installation folder can be different
// than the root `installDir`. For example if the .quadlets
// file is part of an application and is in a subdirectory.
sectionsDestDir := filepath.Dir(quadletPath.dst)
// Install each quadlet section as a separate file
for _, section := range sections {
installedPath, err := installMultiQuadletSection(ctx, ic, section, sectionsDestDir, options.Replace)
if err != nil {
installReport.QuadletErrors[quadletPath.src] = fmt.Errorf("unable to install multi-quadlet section %w", err)
continue
}
}()
_, err = io.Copy(tmpFile, r.Body)
// Record the installation (use a unique key for each section)
sectionKey := fmt.Sprintf("%s#%s", quadletPath.src, filepath.Base(installedPath))
installReport.InstalledQuadlets[sectionKey] = installedPath
}
case systemdquadlet.IsExtSupported(quadletPath.src) ||
options.Application != "":
// If quadletPath is a single file with a supported extension, or
// if it isn't but it's part of an application, execute the original logic
installedPath, err := ic.installQuadlet(ctx, quadletPath.src, quadletPath.dst, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("populating temporary file: %w", err)
installReport.QuadletErrors[quadletPath.src] = err
continue
}
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), quadletFileName, installDir, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
installReport.InstalledQuadlets[quadletPath.src] = installedPath
default:
err := fileutils.Exists(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
// Check if this file has a supported extension or is a .quadlets file
isQuadletsFile := filepath.Ext(toInstall) == ".quadlets"
if isQuadletsFile {
// Parse the multi-quadlet file
quadlets, err := parseMultiQuadletFile(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
// Install each quadlet section as a separate file
for _, quadlet := range quadlets {
// Create a temporary file for this quadlet section
tmpFile, err := os.CreateTemp("", quadlet.name+"*"+quadlet.extension)
if err != nil {
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 {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
continue
}
// Install the quadlet from the temporary file
destName := quadlet.name + quadlet.extension
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", destName, err)
continue
}
// Record the installation (use a unique key for each section)
sectionKey := fmt.Sprintf("%s#%s", toInstall, destName)
installReport.InstalledQuadlets[sectionKey] = installedPath
}
} else {
// If toInstall is a single file with a supported extension, execute the original logic
installedPath, err := ic.installQuadlet(ctx, toInstall, filepath.Base(toInstall), installDir, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
}
installReport.QuadletErrors[quadletPath.src] = fmt.Errorf("unsupported quadlet extension (%q)", quadletExt)
}
}
@ -215,6 +191,90 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
return &installReport, nil
}
func installQuadletFromURL(ctx context.Context, ic *ContainerEngine, quadletURL string, installDir string, replace bool) (string, error) {
r, err := http.Get(quadletURL)
if err != nil {
return "", fmt.Errorf("unable to download URL %s: %w", quadletURL, err)
}
defer r.Body.Close()
quadletFileName, err := getFileName(r, quadletURL)
if err != nil {
return "", fmt.Errorf("unable to get file name from url %s: %w", quadletURL, err)
}
// It's a URL. Pull to temporary file.
tmpFile, err := os.CreateTemp("", quadletFileName)
if err != nil {
return "", fmt.Errorf("unable to create temporary file to download URL %s: %w", quadletURL, err)
}
defer func() {
tmpFile.Close()
if err := os.Remove(tmpFile.Name()); err != nil {
logrus.Errorf("unable to remove temporary file %q: %v", tmpFile.Name(), err)
}
}()
_, err = io.Copy(tmpFile, r.Body)
if err != nil {
return "", fmt.Errorf("populating temporary file: %w", err)
}
return ic.installQuadlet(ctx, tmpFile.Name(), filepath.Join(installDir, quadletFileName), replace)
}
func installMultiQuadletSection(ctx context.Context, ic *ContainerEngine, section quadletSection, installDir string, replace bool) (string, error) {
tmpFile, err := os.CreateTemp("", section.name+"*"+section.extension)
if err != nil {
return "", fmt.Errorf("unable to create temporary file for quadlet section %s: %w", section.name, err)
}
defer func() {
tmpFile.Close()
if err := os.Remove(tmpFile.Name()); err != nil {
logrus.Errorf("unable to remove temporary file %q: %v", tmpFile.Name(), err)
}
}()
// Write the quadlet content to the temporary file
_, err = tmpFile.WriteString(section.content)
if err != nil {
return "", fmt.Errorf("unable to write quadlet section %s to temporary file: %w", section.name, err)
}
// Install the quadlet from the temporary file
destName := section.name + section.extension
return ic.installQuadlet(ctx, tmpFile.Name(), filepath.Join(installDir, destName), replace)
}
func isFolder(s string) bool {
if isURL(s) {
return false
}
info, err := os.Stat(s)
if err != nil {
return false
}
return info.IsDir()
}
func isURL(s string) bool {
return strings.HasPrefix(s, "http://") ||
strings.HasPrefix(s, "https://")
}
func findNestedQuadlets(folderPath string) ([]string, error) {
// If it's a directory, then read all files and add it to paths
quadletPaths := make([]string, 0)
err := filepath.WalkDir(folderPath, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("unable to read Quadlet dir %s: %w", path, err)
}
if !d.IsDir() {
quadletPaths = append(quadletPaths, path)
}
return nil
})
if err != nil {
return nil, err
}
return quadletPaths, nil
}
// Extracts file name from Content-Disposition or URL
func getFileName(resp *http.Response, fileURL string) (string, error) {
// Try to get filename from Content-Disposition header
@ -240,7 +300,7 @@ func getFileName(resp *http.Response, fileURL string) (string, error) {
// Perform some minimal validation, but not much.
// We can't know about a lot of problems without running the Quadlet binary, which we
// only want to do once.
func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, installDir string, replace bool) (string, error) {
func (ic *ContainerEngine) installQuadlet(ctx context.Context, srcPath, destPath string, replace bool) (string, error) {
select {
case <-ctx.Done():
return "", fmt.Errorf("context cancelled: %w", ctx.Err())
@ -248,57 +308,31 @@ func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, i
}
// First, validate that the source path exists and is a file
stat, err := os.Stat(path)
_, err := os.Stat(srcPath)
if err != nil {
return "", fmt.Errorf("quadlet to install %q does not exist or cannot be read: %w", path, err)
}
if stat.IsDir() {
dirs, err := os.ReadDir(path)
if err != nil {
return "", err
}
for _, d := range dirs {
nInstallDir := filepath.Join(installDir, destName)
err := os.MkdirAll(nInstallDir, 0o755)
if err != nil {
return "", err
}
_, err = ic.installQuadlet(
ctx,
filepath.Join(path, d.Name()), // path
d.Name(), // destName
nInstallDir, // installDir
replace)
if err != nil {
return "", err
}
}
return path, nil
return "", fmt.Errorf("quadlet to install %q does not exist or cannot be read: %w", srcPath, err)
}
finalPath := filepath.Join(installDir, filepath.Base(filepath.Clean(path)))
if destName != "" {
finalPath = filepath.Join(installDir, destName)
// Second, create the destPath folder as it may not exist yet
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", fmt.Errorf("unable to create Quadlet install path %s: %w", destPath, err)
}
var destFile *os.File
var tempPath string
if !replace {
var err error
// O_EXCL ensures we fail if the file already exists (avoids TOCTOU race)
destFile, err = os.OpenFile(finalPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
destFile, err = os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o644)
if err != nil {
if errors.Is(err, fs.ErrExist) {
return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(finalPath))
return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(destPath))
}
return "", fmt.Errorf("unable to open file %s: %w", finalPath, err)
return "", fmt.Errorf("unable to open file %s: %w", destPath, err)
}
} else {
var err error
destFile, err = os.CreateTemp(filepath.Dir(finalPath), ".quadlet-install-*")
destFile, err = os.CreateTemp(filepath.Dir(destPath), ".quadlet-install-*")
if err != nil {
return "", fmt.Errorf("unable to create temp file: %w", err)
}
@ -314,7 +348,7 @@ func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, i
}
}()
srcFile, err := os.Open(path)
srcFile, err := os.Open(srcPath)
if err != nil {
return "", fmt.Errorf("unable to open file: %w", err)
}
@ -322,7 +356,7 @@ func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, i
err = fileutils.ReflinkOrCopy(srcFile, destFile)
if err != nil {
return "", fmt.Errorf("unable to copy file from %s to %s: %w", path, finalPath, err)
return "", fmt.Errorf("unable to copy file from %s to %s: %w", srcPath, destPath, err)
}
// Close before rename to flush writes; nil out to prevent double-close in defer
@ -336,12 +370,12 @@ func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, i
return "", fmt.Errorf("unable to set permissions on temp file: %w", err)
}
if err := os.Rename(tempPath, finalPath); err != nil {
return "", fmt.Errorf("unable to rename temp file to %s: %w", finalPath, err)
if err := os.Rename(tempPath, destPath); err != nil {
return "", fmt.Errorf("unable to rename temp file to %s: %w", destPath, err)
}
tempPath = ""
}
return finalPath, nil
return destPath, nil
}
// quadletSection represents a single quadlet extracted from a multi-quadlet file

View file

@ -281,27 +281,27 @@ EOF
echo "$quadlet_6_content" > "$TMPD/$quadlet_6"
echo "$containerfile_2_content" > "$TMPD/$containerfile_2"
t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t POST "libpod/quadlets?application=foo" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_content" "quadlet-6 should be installed"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should be installed"
is "$(cat "$quadlet_install_dir/foo/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should be installed"
# update with no replace and check nothing changed
echo "$quadlet_6_updated_content" > "$TMPD/$quadlet_6"
echo "$containerfile_2_updated_content" > "$TMPD/$containerfile_2"
t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 400
t POST "libpod/quadlets?application=foo" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 400
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_content" "quadlet-6 should not be updated"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should not be updated"
is "$(cat "$quadlet_install_dir/foo/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should not be updated"
# replace
t POST "libpod/quadlets?replace=true" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t POST "libpod/quadlets?application=foo&replace=true" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200
t GET "libpod/quadlets/$quadlet_6/file" 200
is "$output" "$quadlet_6_updated_content" "quadlet-6 should be updated"
is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_updated_content" "containerfile_2 should be updated"
is "$(cat "$quadlet_install_dir/foo/$containerfile_2")" "$containerfile_2_updated_content" "containerfile_2 should be updated"
# Scenario: install and remove quadlets as application

View file

@ -260,6 +260,14 @@ EOF
Image=$IMAGE
Environment=FOO1=foo1
Exec=sh -c "echo STARTED NGINX; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
EOF
mkdir $quadlet_dir/sub
cat > $quadlet_dir/sub/nginxsub.container <<EOF
[Container]
Image=$IMAGE
Environment=FOO2=foo2
Exec=sh -c "echo STARTED NGINX SUB; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
EOF
# Without --application should fail
@ -268,18 +276,21 @@ EOF
# Test quadlet install with directory
run_podman quadlet install --application=foo $quadlet_dir
assert "$output" =~ "nginxsub.container" "install should list nginxsub that is in a subfolder"
# Test quadlet list to verify all containers were installed
run_podman quadlet list
assert "$output" =~ "alpine1.container" "list should contain alpine1.container"
assert "$output" =~ "alpine2.container" "list should contain alpine2.container"
assert "$output" =~ "nginx.container" "list should contain nginx.container"
assert "$output" =~ "nginxsub.container" "list should contain nginxsub.container"
# Test quadlet list with filter for alpine containers
run_podman quadlet list --filter name=alpine*
assert "$output" =~ "alpine1.container" "filtered list should contain alpine1.container"
assert "$output" =~ "alpine2.container" "filtered list should contain alpine2.container"
assert "$output" !~ "nginx.container" "filtered list should not contain nginx.container"
assert "$output" !~ "nginxsub.container" "filtered list should not contain nginxsub.container"
# Test quadlet print for each container
run_podman quadlet print alpine1.container
@ -291,6 +302,9 @@ EOF
run_podman quadlet print nginx.container
assert "$output" =~ "Environment=FOO1=foo1" "print should contain environment for nginx container"
run_podman quadlet print nginxsub.container
assert "$output" =~ "Environment=FOO2=foo2" "print should contain environment for nginxsub container"
# Test quadlet rm using one quadlet file name without recursive (should fail)
run_podman 125 quadlet rm "alpine1.container"
assert "$output" =~ "recursive option is not set" "rm application without --recursive must fail"
@ -401,7 +415,7 @@ EOF
run_podman quadlet rm --recursive mount-test.container
# Verify the test.txt file should not exists in $install_dir
if [[ -f "$install_dir/test.txt" ]]; then
if [[ -f "$install_dir/bar/test.txt" ]]; then
die "test.txt file should not exist in install directory $install_dir after removal"
fi

View file

@ -260,6 +260,7 @@ EOF
local api_name="api-server_$(random_string)"
local cache_name="cache_$(random_string)"
local network_name="app-network_$(random_string)"
local nested_server_name="nested-server_$(random_string)"
# Create an individual container quadlet file
cat > "$app_dir/${frontend_name}.container" <<EOF
@ -307,18 +308,30 @@ debug=true
port=3000
EOF
# Create a nested .quadlets file
mkdir "$app_dir/a"
cat > "$app_dir/a/backend_$(random_string).quadlets" <<EOF
# FileName=$nested_server_name
[Container]
Image=$IMAGE
ContainerName=nested-server-$(random_string)
PublishPort=8080:8080
EOF
# Install the directory
run_podman quadlet install "$app_dir" --application=$app_name
# Verify all quadlets were installed (2 individual + 3 from .quadlets file = 5 total)
# Verify all quadlets were installed (2 individual + 3 from .quadlets file + 1 from nested .quadlet file = 6 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"
assert "$output" =~ "${nested_server_name}.container" "install output should contain ${nested_server_name}.container"
# Count lines in output (should be 6 lines: 5 quadlets + 1 asset file)
assert "${#lines[@]}" -eq 6 "install output should contain exactly six lines"
# Count lines in output (should be 7 lines: 6 quadlets + 1 asset file)
assert "${#lines[@]}" -eq 7 "install output should contain exactly seven lines"
# Verify all files exist on disk
[[ -f "$install_dir/$app_name/${frontend_name}.container" ]] || die "${frontend_name}.container should exist on disk"
@ -327,6 +340,7 @@ EOF
[[ -f "$install_dir/$app_name/${cache_name}.volume" ]] || die "${cache_name}.volume should exist on disk"
[[ -f "$install_dir/$app_name/${network_name}.network" ]] || die "${network_name}.network should exist on disk"
[[ -f "$install_dir/$app_name/app.conf" ]] || die "app.conf should exist on disk"
[[ -f "$install_dir/$app_name/a/${nested_server_name}.container" ]] || die "a/${nested_server_name}.container should exist on disk"
# Test quadlet list to verify all quadlets show the same app name
run_podman quadlet list
@ -335,6 +349,7 @@ EOF
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")
local nested_server_line=$(echo "$output" | grep "${nested_server_name}.container")
# Verify content of individual quadlet files
run cat "$install_dir/$app_name/${frontend_name}.container"
@ -349,6 +364,10 @@ EOF
assert "$output" =~ "\\[Network\\]" "network file should contain [Network] section"
assert "$output" =~ "Subnet=192.168.1.0/24" "network file should contain correct subnet"
run cat "$install_dir/$app_name/a/${nested_server_name}.container"
assert "$output" =~ "\\[Container\\]" "nested-server container file should contain [Container] section"
assert "$output" =~ "ContainerName=nested-server-" "nested-server container file should contain correct name prefix"
# Test that removing one quadlet removes the entire application
run_podman quadlet rm $app_name --recursive
@ -359,6 +378,7 @@ EOF
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"
assert "$output" !~ "${nested_server_name}.container" "${nested_server_name}.container should also be removed as part of same app"
# All individual files should be removed
[[ ! -f "$install_dir/$app_name/${frontend_name}.container" ]] || die "${frontend_name}.container should be removed"
@ -367,4 +387,5 @@ EOF
[[ ! -f "$install_dir/$app_name/${cache_name}.volume" ]] || die "${cache_name}.volume should be removed"
[[ ! -f "$install_dir/$app_name/${network_name}.network" ]] || die "${network_name}.network should be removed"
[[ ! -f "$install_dir/$app_name/app.conf" ]] || die "app.conf should be removed"
[[ ! -f "$install_dir/$app_name/a/${nested_server_name}.container" ]] || die "${nested_server_name}.container should be removed"
}