Skip to content

Commit e787b4f

Browse files
committed
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>
1 parent 17beac1 commit e787b4f

File tree

3 files changed

+462
-6
lines changed

3 files changed

+462
-6
lines changed

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ 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.
20+
1921
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.
2022

2123
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
5961
/home/user/.config/containers/systemd/basic.container
6062
```
6163

64+
Install multiple quadlets from a single .quadlets file
65+
```
66+
$ cat webapp.quadlets
67+
# FileName=web-server
68+
[Container]
69+
Image=nginx:latest
70+
ContainerName=web-server
71+
PublishPort=8080:80
72+
73+
---
74+
75+
# FileName=app-storage
76+
[Volume]
77+
Label=app=webapp
78+
79+
---
80+
81+
# FileName=app-network
82+
[Network]
83+
Subnet=10.0.0.0/24
84+
85+
$ podman quadlet install webapp.quadlets
86+
/home/user/.config/containers/systemd/web-server.container
87+
/home/user/.config/containers/systemd/app-storage.volume
88+
/home/user/.config/containers/systemd/app-network.network
89+
```
90+
91+
Note: Multi-quadlet functionality requires the `.quadlets` file extension. Files with other extensions will only be processed as single quadlets or asset files.
92+
6293
## SEE ALSO
6394
**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**

pkg/domain/infra/abi/quadlet.go

Lines changed: 249 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,90 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
209209
installReport.QuadletErrors[toInstall] = err
210210
continue
211211
}
212-
// If toInstall is a single file, execute the original logic
213-
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace)
214-
if err != nil {
215-
installReport.QuadletErrors[toInstall] = err
216-
continue
212+
213+
// Check if this file has a supported extension or is a .quadlets file
214+
hasValidExt := systemdquadlet.IsExtSupported(toInstall)
215+
ext := strings.ToLower(filepath.Ext(toInstall))
216+
isQuadletsFile := ext == ".quadlets"
217+
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+
231+
// Handle files with unsupported extensions that are not .quadlets files
232+
if !hasValidExt && !isQuadletsFile {
233+
// If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
234+
if assetFile != "" {
235+
// This is part of an app installation, allow non-quadlet files as assets
236+
// Don't validate as quadlet file (validateQuadletFile will be false)
237+
} else {
238+
// Standalone files with unsupported extensions are not allowed
239+
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
240+
continue
241+
}
242+
}
243+
244+
if isMulti {
245+
// Parse the multi-quadlet file
246+
quadlets, err := parseMultiQuadletFile(toInstall)
247+
if err != nil {
248+
installReport.QuadletErrors[toInstall] = err
249+
continue
250+
}
251+
252+
// Install each quadlet section as a separate file
253+
for _, quadlet := range quadlets {
254+
// Create a temporary file for this quadlet section
255+
tmpFile, err := os.CreateTemp("", quadlet.name+"*"+quadlet.extension)
256+
if err != nil {
257+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err)
258+
continue
259+
}
260+
261+
// Write the quadlet content to the temporary file
262+
_, err = tmpFile.WriteString(quadlet.content)
263+
if err != nil {
264+
tmpFile.Close()
265+
os.Remove(tmpFile.Name())
266+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
267+
continue
268+
}
269+
tmpFile.Close()
270+
271+
// Install the quadlet from the temporary file
272+
destName := quadlet.name + quadlet.extension
273+
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace)
274+
if err != nil {
275+
os.Remove(tmpFile.Name())
276+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", quadlet.name, err)
277+
continue
278+
}
279+
280+
// Clean up temporary file
281+
os.Remove(tmpFile.Name())
282+
283+
// Record the installation (use a unique key for each section)
284+
sectionKey := fmt.Sprintf("%s#%s", toInstall, quadlet.name)
285+
installReport.InstalledQuadlets[sectionKey] = installedPath
286+
}
287+
} else {
288+
// If toInstall is a single file with a supported extension, execute the original logic
289+
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace)
290+
if err != nil {
291+
installReport.QuadletErrors[toInstall] = err
292+
continue
293+
}
294+
installReport.InstalledQuadlets[toInstall] = installedPath
217295
}
218-
installReport.InstalledQuadlets[toInstall] = installedPath
219296
}
220297
}
221298

@@ -325,6 +402,172 @@ func appendStringToFile(filePath, text string) error {
325402
return err
326403
}
327404

405+
// quadletSection represents a single quadlet extracted from a multi-quadlet file
406+
type quadletSection struct {
407+
content string
408+
extension string
409+
name string
410+
}
411+
412+
// parseMultiQuadletFile parses a file that may contain multiple quadlets separated by "---"
413+
// Returns a slice of quadletSection structs, each representing a separate quadlet
414+
func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
415+
content, err := os.ReadFile(filePath)
416+
if err != nil {
417+
return nil, fmt.Errorf("unable to read file %s: %w", filePath, err)
418+
}
419+
420+
// Split content by lines and reconstruct sections manually to handle "---" properly
421+
lines := strings.Split(string(content), "\n")
422+
var sections []string
423+
var currentSection strings.Builder
424+
425+
for _, line := range lines {
426+
if strings.TrimSpace(line) == "---" {
427+
// Found separator, save current section and start new one
428+
if currentSection.Len() > 0 {
429+
sections = append(sections, currentSection.String())
430+
currentSection.Reset()
431+
}
432+
} else {
433+
// Add line to current section
434+
if currentSection.Len() > 0 {
435+
currentSection.WriteString("\n")
436+
}
437+
currentSection.WriteString(line)
438+
}
439+
}
440+
441+
// Add the last section
442+
if currentSection.Len() > 0 {
443+
sections = append(sections, currentSection.String())
444+
}
445+
446+
baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath))
447+
isMultiSection := len(sections) > 1
448+
449+
// Pre-allocate slice with capacity based on number of sections
450+
quadlets := make([]quadletSection, 0, len(sections))
451+
452+
for i, section := range sections {
453+
// Trim whitespace from section
454+
section = strings.TrimSpace(section)
455+
if section == "" {
456+
continue // Skip empty sections
457+
}
458+
459+
// Determine quadlet type from section content
460+
extension, err := detectQuadletType(section)
461+
if err != nil {
462+
return nil, fmt.Errorf("unable to detect quadlet type in section %d: %w", i+1, err)
463+
}
464+
465+
// Extract name for this quadlet section
466+
var name string
467+
if isMultiSection {
468+
// For multi-section files, extract FileName from comments
469+
fileName, err := extractFileNameFromSection(section)
470+
if err != nil {
471+
return nil, fmt.Errorf("section %d: %w", i+1, err)
472+
}
473+
name = fileName
474+
} else {
475+
// Single section, use original name
476+
name = baseName
477+
}
478+
479+
quadlets = append(quadlets, quadletSection{
480+
content: section,
481+
extension: extension,
482+
name: name,
483+
})
484+
}
485+
486+
if len(quadlets) == 0 {
487+
return nil, fmt.Errorf("no valid quadlet sections found in file %s", filePath)
488+
}
489+
490+
return quadlets, nil
491+
}
492+
493+
// extractFileNameFromSection extracts the FileName from a comment in the quadlet section
494+
// The comment must be in the format: # FileName=my-name
495+
func extractFileNameFromSection(content string) (string, error) {
496+
lines := strings.Split(content, "\n")
497+
for _, line := range lines {
498+
line = strings.TrimSpace(line)
499+
// Look for comment lines starting with #
500+
if strings.HasPrefix(line, "#") {
501+
// Remove the # and trim whitespace
502+
commentContent := strings.TrimSpace(line[1:])
503+
// Check if it's a FileName directive
504+
if strings.HasPrefix(commentContent, "FileName=") {
505+
fileName := strings.TrimSpace(commentContent[9:]) // Remove "FileName="
506+
if fileName == "" {
507+
return "", fmt.Errorf("FileName comment found but no filename specified")
508+
}
509+
// Validate filename (basic validation - no path separators, no extensions)
510+
if strings.ContainsAny(fileName, "/\\") {
511+
return "", fmt.Errorf("FileName '%s' cannot contain path separators", fileName)
512+
}
513+
if strings.Contains(fileName, ".") {
514+
return "", fmt.Errorf("FileName '%s' should not include file extension", fileName)
515+
}
516+
return fileName, nil
517+
}
518+
}
519+
}
520+
return "", fmt.Errorf("missing required '# FileName=<name>' comment at the beginning of quadlet section")
521+
}
522+
523+
// detectQuadletType analyzes the content of a quadlet section to determine its type
524+
// Returns the appropriate file extension (.container, .volume, .network, etc.)
525+
func detectQuadletType(content string) (string, error) {
526+
// Look for section headers like [Container], [Volume], [Network], etc.
527+
lines := strings.Split(content, "\n")
528+
for _, line := range lines {
529+
line = strings.TrimSpace(line)
530+
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
531+
sectionName := strings.ToLower(strings.Trim(line, "[]"))
532+
switch sectionName {
533+
case "container":
534+
return ".container", nil
535+
case "volume":
536+
return ".volume", nil
537+
case "network":
538+
return ".network", nil
539+
case "kube":
540+
return ".kube", nil
541+
case "image":
542+
return ".image", nil
543+
case "build":
544+
return ".build", nil
545+
case "pod":
546+
return ".pod", nil
547+
}
548+
}
549+
}
550+
return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])")
551+
}
552+
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+
328571
// buildAppMap scans the given directory for files that start with '.'
329572
// and end with '.app', reads their contents (one filename per line), and
330573
// returns a map where each filename maps to the .app file that contains it.

0 commit comments

Comments
 (0)