Skip to content

Commit 8ea91d2

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. Signed-off-by: flouthoc <[email protected]>
1 parent 5a0b74b commit 8ea91d2

File tree

4 files changed

+931
-4
lines changed

4 files changed

+931
-4
lines changed

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

Lines changed: 29 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 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,32 @@ $ 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 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+
6291
## SEE ALSO
6392
**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**

pkg/domain/infra/abi/quadlet.go

Lines changed: 221 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,66 @@ 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)
212+
213+
// Check if this is a multi-quadlet file
214+
isMulti, err := isMultiQuadletFile(toInstall)
214215
if err != nil {
215-
installReport.QuadletErrors[toInstall] = err
216+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err)
216217
continue
217218
}
218-
installReport.InstalledQuadlets[toInstall] = installedPath
219+
220+
if isMulti {
221+
// Parse the multi-quadlet file
222+
quadlets, err := parseMultiQuadletFile(toInstall)
223+
if err != nil {
224+
installReport.QuadletErrors[toInstall] = err
225+
continue
226+
}
227+
228+
// Install each quadlet section as a separate file
229+
for _, quadlet := range quadlets {
230+
// Create a temporary file for this quadlet section
231+
tmpFile, err := os.CreateTemp("", quadlet.name+"*"+quadlet.extension)
232+
if err != nil {
233+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err)
234+
continue
235+
}
236+
237+
// Write the quadlet content to the temporary file
238+
_, err = tmpFile.WriteString(quadlet.content)
239+
if err != nil {
240+
tmpFile.Close()
241+
os.Remove(tmpFile.Name())
242+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
243+
continue
244+
}
245+
tmpFile.Close()
246+
247+
// Install the quadlet from the temporary file
248+
destName := quadlet.name + quadlet.extension
249+
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace)
250+
if err != nil {
251+
os.Remove(tmpFile.Name())
252+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", quadlet.name, err)
253+
continue
254+
}
255+
256+
// Clean up temporary file
257+
os.Remove(tmpFile.Name())
258+
259+
// Record the installation (use a unique key for each section)
260+
sectionKey := fmt.Sprintf("%s#%s", toInstall, quadlet.name)
261+
installReport.InstalledQuadlets[sectionKey] = installedPath
262+
}
263+
} else {
264+
// If toInstall is a single file, execute the original logic
265+
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace)
266+
if err != nil {
267+
installReport.QuadletErrors[toInstall] = err
268+
continue
269+
}
270+
installReport.InstalledQuadlets[toInstall] = installedPath
271+
}
219272
}
220273
}
221274

@@ -325,6 +378,170 @@ func appendStringToFile(filePath, text string) error {
325378
return err
326379
}
327380

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

0 commit comments

Comments
 (0)