Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion docs/source/markdown/podman-quadlet-install.1.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +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 ).

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.
* 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. 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.

Expand Down Expand Up @@ -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)**
242 changes: 235 additions & 7 deletions pkg/domain/infra/abi/quadlet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := strings.ToLower(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 {
Expand Down Expand Up @@ -209,13 +217,77 @@ 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"

// 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 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
}

// 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
}
}

Expand Down Expand Up @@ -308,6 +380,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
}
Expand All @@ -325,6 +405,154 @@ 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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you have already read the file in isMultiQuadletFile(toInstall) prior. Would it make sense to load the file into an array or some other data structure in isMultiQuadletFile() to avoid another read here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is removed.

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])")
}

// 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.
Expand Down
Loading