Skip to content

Commit f90c8a5

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 f90c8a5

File tree

4 files changed

+948
-4
lines changed

4 files changed

+948
-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: 238 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,13 +209,81 @@ 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 file has a supported extension or could be a multi-quadlet file
214+
hasValidExt := systemdquadlet.IsExtSupported(toInstall)
215+
isMulti, err := isMultiQuadletFile(toInstall)
214216
if err != nil {
215-
installReport.QuadletErrors[toInstall] = err
217+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err)
216218
continue
217219
}
218-
installReport.InstalledQuadlets[toInstall] = installedPath
220+
221+
// If the file doesn't have a valid extension, it must be a multi-quadlet file or contain quadlets
222+
if !hasValidExt && !isMulti {
223+
// Try to parse it as a potential single quadlet in an unsupported extension file
224+
quadlets, parseErr := parseMultiQuadletFile(toInstall)
225+
if parseErr != nil || len(quadlets) == 0 {
226+
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
227+
continue
228+
}
229+
// If we successfully parsed quadlets, treat it as a multi-quadlet file
230+
isMulti = true
231+
}
232+
233+
if isMulti {
234+
// Parse the multi-quadlet file
235+
quadlets, err := parseMultiQuadletFile(toInstall)
236+
if err != nil {
237+
installReport.QuadletErrors[toInstall] = err
238+
continue
239+
}
240+
241+
// Install each quadlet section as a separate file
242+
for _, quadlet := range quadlets {
243+
// Create a temporary file for this quadlet section
244+
tmpFile, err := os.CreateTemp("", quadlet.name+"*"+quadlet.extension)
245+
if err != nil {
246+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err)
247+
continue
248+
}
249+
250+
// Write the quadlet content to the temporary file
251+
_, err = tmpFile.WriteString(quadlet.content)
252+
if err != nil {
253+
tmpFile.Close()
254+
os.Remove(tmpFile.Name())
255+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
256+
continue
257+
}
258+
tmpFile.Close()
259+
260+
// Install the quadlet from the temporary file
261+
destName := quadlet.name + quadlet.extension
262+
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace)
263+
if err != nil {
264+
os.Remove(tmpFile.Name())
265+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", quadlet.name, err)
266+
continue
267+
}
268+
269+
// Clean up temporary file
270+
os.Remove(tmpFile.Name())
271+
272+
// Record the installation (use a unique key for each section)
273+
sectionKey := fmt.Sprintf("%s#%s", toInstall, quadlet.name)
274+
installReport.InstalledQuadlets[sectionKey] = installedPath
275+
}
276+
} else {
277+
// If toInstall is a single file, execute the original logic
278+
// Don't validate extension for multi-quadlet files that were detected but had only one section
279+
shouldValidate := validateQuadletFile
280+
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, shouldValidate, options.Replace)
281+
if err != nil {
282+
installReport.QuadletErrors[toInstall] = err
283+
continue
284+
}
285+
installReport.InstalledQuadlets[toInstall] = installedPath
286+
}
219287
}
220288
}
221289

@@ -325,6 +393,172 @@ func appendStringToFile(filePath, text string) error {
325393
return err
326394
}
327395

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

0 commit comments

Comments
 (0)