quadlet: add support for multiple quadlets in a single file

Enable installing multiple quadlets from one file using '---' delimiters.
Each section requires '# FileName=<name>' comment for custom naming.
Single quadlet files remain unchanged for backward compatibility.

Assited by: claude-4-sonnet

Signed-off-by: flouthoc <flouthoc.git@gmail.com>
This commit is contained in:
flouthoc
2025-10-26 22:38:36 -07:00
parent 17beac160c
commit e787b4f503
3 changed files with 462 additions and 6 deletions

View File

@@ -16,6 +16,8 @@ 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.
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: In case user wants to install Quadlet application then first path should be the path to application directory.
@@ -59,5 +61,34 @@ $ podman quadlet install https://github.com/containers/podman/blob/main/test/e2e
/home/user/.config/containers/systemd/basic.container
```
Install multiple quadlets from a single .quadlets file
```
$ cat webapp.quadlets
# FileName=web-server
[Container]
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
$ podman quadlet install webapp.quadlets
/home/user/.config/containers/systemd/web-server.container
/home/user/.config/containers/systemd/app-storage.volume
/home/user/.config/containers/systemd/app-network.network
```
Note: Multi-quadlet functionality requires the `.quadlets` file extension. Files with other extensions will only be processed as single quadlets or asset files.
## SEE ALSO
**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**

View File

@@ -209,13 +209,90 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
installReport.QuadletErrors[toInstall] = err
continue
}
// If toInstall is a single file, execute the original logic
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
// 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
}
// 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 isMulti {
// 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
}
// Write the quadlet content to the temporary file
_, err = tmpFile.WriteString(quadlet.content)
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)
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)
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, "", installDir, assetFile, validateQuadletFile, options.Replace)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
}
installReport.InstalledQuadlets[toInstall] = installedPath
}
}
@@ -325,6 +402,172 @@ func appendStringToFile(filePath, text string) error {
return err
}
// quadletSection represents a single quadlet extracted from a multi-quadlet file
type quadletSection struct {
content string
extension string
name string
}
// parseMultiQuadletFile parses a file that may contain multiple quadlets separated by "---"
// Returns a slice of quadletSection structs, each representing a separate quadlet
func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("unable to read file %s: %w", filePath, err)
}
// Split content by lines and reconstruct sections manually to handle "---" properly
lines := strings.Split(string(content), "\n")
var sections []string
var currentSection strings.Builder
for _, line := range lines {
if strings.TrimSpace(line) == "---" {
// Found separator, save current section and start new one
if currentSection.Len() > 0 {
sections = append(sections, currentSection.String())
currentSection.Reset()
}
} else {
// Add line to current section
if currentSection.Len() > 0 {
currentSection.WriteString("\n")
}
currentSection.WriteString(line)
}
}
// Add the last section
if currentSection.Len() > 0 {
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))
for i, section := range sections {
// Trim whitespace from section
section = strings.TrimSpace(section)
if section == "" {
continue // Skip empty sections
}
// Determine quadlet type from section content
extension, err := detectQuadletType(section)
if err != nil {
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
}
quadlets = append(quadlets, quadletSection{
content: section,
extension: extension,
name: name,
})
}
if len(quadlets) == 0 {
return nil, fmt.Errorf("no valid quadlet sections found in file %s", filePath)
}
return quadlets, nil
}
// extractFileNameFromSection extracts the FileName from a comment in the quadlet section
// The comment must be in the format: # FileName=my-name
func extractFileNameFromSection(content string) (string, error) {
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Look for comment lines starting with #
if strings.HasPrefix(line, "#") {
// Remove the # and trim whitespace
commentContent := strings.TrimSpace(line[1:])
// Check if it's a FileName directive
if strings.HasPrefix(commentContent, "FileName=") {
fileName := strings.TrimSpace(commentContent[9:]) // Remove "FileName="
if fileName == "" {
return "", fmt.Errorf("FileName comment found but no filename specified")
}
// Validate filename (basic validation - no path separators, no extensions)
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
}
}
}
return "", fmt.Errorf("missing required '# FileName=<name>' comment at the beginning of quadlet section")
}
// detectQuadletType analyzes the content of a quadlet section to determine its type
// Returns the appropriate file extension (.container, .volume, .network, etc.)
func detectQuadletType(content string) (string, error) {
// Look for section headers like [Container], [Volume], [Network], etc.
lines := strings.Split(content, "\n")
for _, line := range lines {
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
}
}
}
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

@@ -0,0 +1,182 @@
#!/usr/bin/env bats -*- bats -*-
#
# Tests for podman quadlet install with multi-quadlet files
#
load helpers
load helpers.systemd
function setup() {
skip_if_remote "podman quadlet is not implemented for remote setup yet"
skip_if_rootless_cgroupsv1 "Can't use --cgroups=split w/ CGv1 (issue 17456, wontfix)"
skip_if_journald_unavailable "Needed for RHEL. FIXME: we might be able to re-enable a subset of tests."
basic_setup
}
function teardown() {
systemctl daemon-reload
basic_teardown
}
# Helper function to get the systemd install directory based on rootless/root mode
function get_quadlet_install_dir() {
if is_rootless; then
# For rootless: $XDG_CONFIG_HOME/containers/systemd or ~/.config/containers/systemd
local config_home=${XDG_CONFIG_HOME:-$HOME/.config}
echo "$config_home/containers/systemd"
else
# For root: /etc/containers/systemd
echo "/etc/containers/systemd"
fi
}
@test "quadlet verb - install multi-quadlet file" {
# 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
cat > $multi_quadlet_file <<EOF
# FileName=webserver
# Web application stack
[Container]
Image=$IMAGE
ContainerName=web-server
PublishPort=8080:80
---
# FileName=appstorage
# Database volume
[Volume]
Label=app=webapp
Label=component=database
---
# FileName=appnetwork
# Application network
[Network]
Subnet=10.0.0.0/24
Gateway=10.0.0.1
Label=app=webapp
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"
# 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"
# 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"
# 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
assert "$output" =~ "\\[Container\\]" "print should show container section"
assert "$output" =~ "Image=$IMAGE" "print should show correct image"
run_podman quadlet print appstorage.volume
assert "$output" =~ "\\[Volume\\]" "print should show volume section"
assert "$output" =~ "Label=app=webapp" "print should show app label"
run_podman quadlet print appnetwork.network
assert "$output" =~ "\\[Network\\]" "print should show network section"
assert "$output" =~ "Subnet=10.0.0.0/24" "print should show subnet"
# Test quadlet rm for one of the quadlets
run_podman quadlet rm webserver.container
assert "$output" =~ "webserver.container" "remove output should contain webserver.container"
# Verify the container quadlet was removed but others remain
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"
# Clean up remaining quadlets
run_podman quadlet rm appstorage.volume appnetwork.network
}
@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
cat > $multi_quadlet_file <<EOF
# FileName=testcontainer
[Container]
Image=$IMAGE
ContainerName=test-container
---
---
# FileName=testvolume
[Volume]
Label=test=value
---
EOF
# Test quadlet install
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 "${#lines[@]}" -eq 2 "install output should contain exactly two lines"
# Clean up
run_podman quadlet rm testcontainer.container testvolume.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
cat > $multi_quadlet_file <<EOF
[Container]
Image=$IMAGE
ContainerName=test-container
---
# FileName=testvolume
[Volume]
Label=test=value
EOF
# Test quadlet install should fail
run_podman 125 quadlet install $multi_quadlet_file
assert "$output" =~ "missing required.*FileName" "error should mention missing FileName"
}