Skip to content

Commit f7f42bb

Browse files
committed
quadlet install: multiple quadlets from single file should share app
Quadlets installed from `.quadlet` file now belongs to a single application, anyone file removed from this application removes all the other files as well. Assited by: claude-4-sonnet Signed-off-by: flouthoc <flouthoc.git@gmail.com>
1 parent e787b4f commit f7f42bb

File tree

3 files changed

+295
-126
lines changed

3 files changed

+295
-126
lines changed

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +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-
* 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.
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, extension of `FileName` is not required as it will be generated by parser internally.
2020

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

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

@@ -69,15 +69,11 @@ $ cat webapp.quadlets
6969
Image=nginx:latest
7070
ContainerName=web-server
7171
PublishPort=8080:80
72-
7372
---
74-
7573
# FileName=app-storage
7674
[Volume]
7775
Label=app=webapp
78-
7976
---
80-
8177
# FileName=app-network
8278
[Network]
8379
Subnet=10.0.0.0/24

pkg/domain/infra/abi/quadlet.go

Lines changed: 26 additions & 60 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 {
@@ -212,36 +220,19 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
212220

213221
// Check if this file has a supported extension or is a .quadlets file
214222
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-
}
223+
isQuadletsFile := filepath.Ext(toInstall) == ".quadlets"
230224

231225
// Handle files with unsupported extensions that are not .quadlets files
232226
if !hasValidExt && !isQuadletsFile {
233227
// 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 {
228+
if assetFile == "" {
238229
// Standalone files with unsupported extensions are not allowed
239230
installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall))
240231
continue
241232
}
242233
}
243234

244-
if isMulti {
235+
if isQuadletsFile {
245236
// Parse the multi-quadlet file
246237
quadlets, err := parseMultiQuadletFile(toInstall)
247238
if err != nil {
@@ -257,11 +248,11 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str
257248
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err)
258249
continue
259250
}
260-
251+
defer os.Remove(tmpFile.Name())
261252
// Write the quadlet content to the temporary file
262253
_, err = tmpFile.WriteString(quadlet.content)
254+
tmpFile.Close()
263255
if err != nil {
264-
tmpFile.Close()
265256
os.Remove(tmpFile.Name())
266257
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err)
267258
continue
@@ -385,6 +376,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins
385376
if err != nil {
386377
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
387378
}
379+
} else if strings.HasSuffix(assetFile, ".app") {
380+
// For quadlet files that are part of an application (indicated by .app extension),
381+
// also write the quadlet filename to the .app file for proper application tracking
382+
quadletName := filepath.Base(finalPath)
383+
err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName)
384+
if err != nil {
385+
return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err)
386+
}
388387
}
389388
return finalPath, nil
390389
}
@@ -430,11 +429,8 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) {
430429
currentSection.Reset()
431430
}
432431
} else {
433-
// Add line to current section
434-
if currentSection.Len() > 0 {
435-
currentSection.WriteString("\n")
436-
}
437432
currentSection.WriteString(line)
433+
currentSection.WriteString("\n")
438434
}
439435
}
440436

@@ -529,45 +525,15 @@ func detectQuadletType(content string) (string, error) {
529525
line = strings.TrimSpace(line)
530526
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
531527
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
528+
expected := "." + sectionName
529+
if systemdquadlet.IsExtSupported("a" + expected) {
530+
return expected, nil
547531
}
548532
}
549533
}
550534
return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])")
551535
}
552536

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-
571537
// buildAppMap scans the given directory for files that start with '.'
572538
// and end with '.app', reads their contents (one filename per line), and
573539
// returns a map where each filename maps to the .app file that contains it.

0 commit comments

Comments
 (0)