Skip to content

Commit fb7e997

Browse files
Merge pull request #27384 from flouthoc/multi-file-quadlet
quadlet install: add support for multiple quadlets in a single file
2 parents 7a2afdf + c22c327 commit fb7e997

File tree

3 files changed

+625
-7
lines changed

3 files changed

+625
-7
lines changed

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ 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-
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.
19+
* Install multiple Quadlets from a single file with the `.quadlets` extension, where each Quadlet is separated by a `---` delimiter. When using multiple quadlets in a single `.quadlets` file, each quadlet section must include a `# FileName=<name>` comment to specify the name for that quadlet.
20+
21+
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.
2022

2123
Note: In case user wants to install Quadlet application then first path should be the path to application directory.
2224

@@ -59,5 +61,30 @@ $ 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+
# FileName=app-storage
74+
[Volume]
75+
Label=app=webapp
76+
---
77+
# FileName=app-network
78+
[Network]
79+
Subnet=10.0.0.0/24
80+
81+
$ podman quadlet install webapp.quadlets
82+
/home/user/.config/containers/systemd/web-server.container
83+
/home/user/.config/containers/systemd/app-storage.volume
84+
/home/user/.config/containers/systemd/app-network.network
85+
```
86+
87+
Note: Multi-quadlet functionality requires the `.quadlets` file extension. Files with other extensions will only be processed as single quadlets or asset files.
88+
6289
## SEE ALSO
6390
**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**, **[podman-systemd.unit(5)](podman-systemd.unit.5.md)**

pkg/domain/infra/abi/quadlet.go

Lines changed: 193 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,15 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
164164
for _, toInstall := range paths {
165165
validateQuadletFile := false
166166
if assetFile == "" {
167-
assetFile = "." + filepath.Base(toInstall) + ".asset"
167+
// Check if this is a .quadlets file - if so, treat as an app
168+
ext := filepath.Ext(toInstall)
169+
if ext == ".quadlets" {
170+
// For .quadlets files, use .app extension to group all quadlets as one application
171+
baseName := strings.TrimSuffix(filepath.Base(toInstall), filepath.Ext(toInstall))
172+
assetFile = "." + baseName + ".app"
173+
} else {
174+
assetFile = "." + filepath.Base(toInstall) + ".asset"
175+
}
168176
validateQuadletFile = true
169177
}
170178
switch {
@@ -209,13 +217,65 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
209217
installReport.QuadletErrors[toInstall] = err
210218
continue
211219
}
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
220+
221+
// Check if this file has a supported extension or is a .quadlets file
222+
hasValidExt := systemdquadlet.IsExtSupported(toInstall)
223+
isQuadletsFile := filepath.Ext(toInstall) == ".quadlets"
224+
225+
// Handle files with unsupported extensions that are not .quadlets files
226+
// If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets
227+
// Standalone files with unsupported extensions are not allowed
228+
if !hasValidExt && !isQuadletsFile && assetFile == "" {
229+
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
216230
continue
217231
}
218-
installReport.InstalledQuadlets[toInstall] = installedPath
232+
233+
if isQuadletsFile {
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+
defer os.Remove(tmpFile.Name())
250+
// Write the quadlet content to the temporary file
251+
_, err = tmpFile.WriteString(quadlet.content)
252+
tmpFile.Close()
253+
if err != nil {
254+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
255+
continue
256+
}
257+
258+
// Install the quadlet from the temporary file
259+
destName := quadlet.name + quadlet.extension
260+
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace)
261+
if err != nil {
262+
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", destName, err)
263+
continue
264+
}
265+
266+
// Record the installation (use a unique key for each section)
267+
sectionKey := fmt.Sprintf("%s#%s", toInstall, destName)
268+
installReport.InstalledQuadlets[sectionKey] = installedPath
269+
}
270+
} else {
271+
// If toInstall is a single file with a supported extension, execute the original logic
272+
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace)
273+
if err != nil {
274+
installReport.QuadletErrors[toInstall] = err
275+
continue
276+
}
277+
installReport.InstalledQuadlets[toInstall] = installedPath
278+
}
219279
}
220280
}
221281

@@ -308,6 +368,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
308368
if err != nil {
309369
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
310370
}
371+
} else if strings.HasSuffix(assetFile, ".app") {
372+
// For quadlet files that are part of an application (indicated by .app extension),
373+
// also write the quadlet filename to the .app file for proper application tracking
374+
quadletName := filepath.Base(finalPath)
375+
err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName)
376+
if err != nil {
377+
return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err)
378+
}
311379
}
312380
return finalPath, nil
313381
}
@@ -325,6 +393,125 @@ 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+
currentSection.WriteString(line)
425+
currentSection.WriteString("\n")
426+
}
427+
}
428+
429+
// Add the last section
430+
if currentSection.Len() > 0 {
431+
sections = append(sections, currentSection.String())
432+
}
433+
434+
// Pre-allocate slice with capacity based on number of sections
435+
quadlets := make([]quadletSection, 0, len(sections))
436+
437+
for i, section := range sections {
438+
// Trim whitespace from section
439+
section = strings.TrimSpace(section)
440+
if section == "" {
441+
continue // Skip empty sections
442+
}
443+
444+
// Determine quadlet type from section content
445+
extension, err := detectQuadletType(section)
446+
if err != nil {
447+
return nil, fmt.Errorf("unable to detect quadlet type in section %d: %w", i+1, err)
448+
}
449+
450+
fileName, err := extractFileNameFromSection(section)
451+
if err != nil {
452+
return nil, fmt.Errorf("section %d: %w", i+1, err)
453+
}
454+
name := fileName
455+
456+
quadlets = append(quadlets, quadletSection{
457+
content: section,
458+
extension: extension,
459+
name: name,
460+
})
461+
}
462+
463+
if len(quadlets) == 0 {
464+
return nil, fmt.Errorf("no valid quadlet sections found in file %s", filePath)
465+
}
466+
467+
return quadlets, nil
468+
}
469+
470+
// extractFileNameFromSection extracts the FileName from a comment in the quadlet section
471+
// The comment must be in the format: # FileName=my-name
472+
func extractFileNameFromSection(content string) (string, error) {
473+
lines := strings.Split(content, "\n")
474+
for _, line := range lines {
475+
line = strings.TrimSpace(line)
476+
// Look for comment lines starting with #
477+
if strings.HasPrefix(line, "#") {
478+
// Remove the # and trim whitespace
479+
commentContent := strings.TrimSpace(line[1:])
480+
// Check if it's a FileName directive
481+
if strings.HasPrefix(commentContent, "FileName=") {
482+
fileName := strings.TrimSpace(commentContent[9:]) // Remove "FileName="
483+
if fileName == "" {
484+
return "", fmt.Errorf("FileName comment found but no filename specified")
485+
}
486+
// Validate filename (basic validation - no path separators)
487+
if strings.ContainsAny(fileName, "/\\") {
488+
return "", fmt.Errorf("FileName '%s' cannot contain path separators", 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+
expected := "." + sectionName
507+
if systemdquadlet.IsExtSupported("a" + expected) {
508+
return expected, nil
509+
}
510+
}
511+
}
512+
return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])")
513+
}
514+
328515
// buildAppMap scans the given directory for files that start with '.'
329516
// and end with '.app', reads their contents (one filename per line), and
330517
// returns a map where each filename maps to the .app file that contains it.

0 commit comments

Comments
 (0)