From e787b4f5031ee76b92b66c1e210370ae112599fa Mon Sep 17 00:00:00 2001 From: flouthoc Date: Sun, 26 Oct 2025 22:38:36 -0700 Subject: [PATCH 1/2] quadlet: add support for multiple quadlets in a single file Enable installing multiple quadlets from one file using '---' delimiters. Each section requires '# FileName=' comment for custom naming. Single quadlet files remain unchanged for backward compatibility. Assited by: claude-4-sonnet Signed-off-by: flouthoc --- .../markdown/podman-quadlet-install.1.md | 31 +++ pkg/domain/infra/abi/quadlet.go | 255 +++++++++++++++++- test/system/254-podman-quadlet-multi.bats | 182 +++++++++++++ 3 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 test/system/254-podman-quadlet-multi.bats diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md index d3e95a349c6..5410f25ec46 100644 --- a/docs/source/markdown/podman-quadlet-install.1.md +++ b/docs/source/markdown/podman-quadlet-install.1.md @@ -16,6 +16,8 @@ This command allows you to: * Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation ( example a config file for a quadlet container ). + * 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=` comment to specify the name for that quadlet. + 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. 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 /home/user/.config/containers/systemd/basic.container ``` +Install multiple quadlets from a single .quadlets file +``` +$ cat webapp.quadlets +# FileName=web-server +[Container] +Image=nginx:latest +ContainerName=web-server +PublishPort=8080:80 + +--- + +# FileName=app-storage +[Volume] +Label=app=webapp + +--- + +# FileName=app-network +[Network] +Subnet=10.0.0.0/24 + +$ podman quadlet install webapp.quadlets +/home/user/.config/containers/systemd/web-server.container +/home/user/.config/containers/systemd/app-storage.volume +/home/user/.config/containers/systemd/app-network.network +``` + +Note: Multi-quadlet functionality requires the `.quadlets` file extension. Files with other extensions will only be processed as single quadlets or asset files. + ## SEE ALSO **[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)** diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go index 9011d79f21e..6140c8e83fb 100644 --- a/pkg/domain/infra/abi/quadlet.go +++ b/pkg/domain/infra/abi/quadlet.go @@ -209,13 +209,90 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str installReport.QuadletErrors[toInstall] = err continue } - // If toInstall is a single file, execute the original logic - installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace) - if err != nil { - installReport.QuadletErrors[toInstall] = err - continue + + // Check if this file has a supported extension or is a .quadlets file + hasValidExt := systemdquadlet.IsExtSupported(toInstall) + ext := strings.ToLower(filepath.Ext(toInstall)) + isQuadletsFile := ext == ".quadlets" + + // Only check for multi-quadlet content if it's a .quadlets file + var isMulti bool + if isQuadletsFile { + var err error + isMulti, err = isMultiQuadletFile(toInstall) + if err != nil { + installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err) + continue + } + // For .quadlets files, always treat as multi-quadlet (even single quadlets) + isMulti = true + } + + // Handle files with unsupported extensions that are not .quadlets files + if !hasValidExt && !isQuadletsFile { + // If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets + if assetFile != "" { + // This is part of an app installation, allow non-quadlet files as assets + // Don't validate as quadlet file (validateQuadletFile will be false) + } else { + // Standalone files with unsupported extensions are not allowed + installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall)) + continue + } + } + + if isMulti { + // Parse the multi-quadlet file + quadlets, err := parseMultiQuadletFile(toInstall) + if err != nil { + installReport.QuadletErrors[toInstall] = err + continue + } + + // Install each quadlet section as a separate file + for _, quadlet := range quadlets { + // Create a temporary file for this quadlet section + tmpFile, err := os.CreateTemp("", quadlet.name+"*"+quadlet.extension) + if err != nil { + installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err) + continue + } + + // Write the quadlet content to the temporary file + _, err = tmpFile.WriteString(quadlet.content) + if err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err) + continue + } + tmpFile.Close() + + // Install the quadlet from the temporary file + destName := quadlet.name + quadlet.extension + installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, assetFile, true, options.Replace) + if err != nil { + os.Remove(tmpFile.Name()) + installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", quadlet.name, err) + continue + } + + // Clean up temporary file + os.Remove(tmpFile.Name()) + + // Record the installation (use a unique key for each section) + sectionKey := fmt.Sprintf("%s#%s", toInstall, quadlet.name) + installReport.InstalledQuadlets[sectionKey] = installedPath + } + } else { + // If toInstall is a single file with a supported extension, execute the original logic + installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile, options.Replace) + if err != nil { + installReport.QuadletErrors[toInstall] = err + continue + } + installReport.InstalledQuadlets[toInstall] = installedPath } - installReport.InstalledQuadlets[toInstall] = installedPath } } @@ -325,6 +402,172 @@ func appendStringToFile(filePath, text string) error { return err } +// quadletSection represents a single quadlet extracted from a multi-quadlet file +type quadletSection struct { + content string + extension string + name string +} + +// parseMultiQuadletFile parses a file that may contain multiple quadlets separated by "---" +// Returns a slice of quadletSection structs, each representing a separate quadlet +func parseMultiQuadletFile(filePath string) ([]quadletSection, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("unable to read file %s: %w", filePath, err) + } + + // Split content by lines and reconstruct sections manually to handle "---" properly + lines := strings.Split(string(content), "\n") + var sections []string + var currentSection strings.Builder + + for _, line := range lines { + if strings.TrimSpace(line) == "---" { + // Found separator, save current section and start new one + if currentSection.Len() > 0 { + sections = append(sections, currentSection.String()) + currentSection.Reset() + } + } else { + // Add line to current section + if currentSection.Len() > 0 { + currentSection.WriteString("\n") + } + currentSection.WriteString(line) + } + } + + // Add the last section + if currentSection.Len() > 0 { + sections = append(sections, currentSection.String()) + } + + baseName := strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) + isMultiSection := len(sections) > 1 + + // Pre-allocate slice with capacity based on number of sections + quadlets := make([]quadletSection, 0, len(sections)) + + for i, section := range sections { + // Trim whitespace from section + section = strings.TrimSpace(section) + if section == "" { + continue // Skip empty sections + } + + // Determine quadlet type from section content + extension, err := detectQuadletType(section) + if err != nil { + return nil, fmt.Errorf("unable to detect quadlet type in section %d: %w", i+1, err) + } + + // Extract name for this quadlet section + var name string + if isMultiSection { + // For multi-section files, extract FileName from comments + fileName, err := extractFileNameFromSection(section) + if err != nil { + return nil, fmt.Errorf("section %d: %w", i+1, err) + } + name = fileName + } else { + // Single section, use original name + name = baseName + } + + quadlets = append(quadlets, quadletSection{ + content: section, + extension: extension, + name: name, + }) + } + + if len(quadlets) == 0 { + return nil, fmt.Errorf("no valid quadlet sections found in file %s", filePath) + } + + return quadlets, nil +} + +// extractFileNameFromSection extracts the FileName from a comment in the quadlet section +// The comment must be in the format: # FileName=my-name +func extractFileNameFromSection(content string) (string, error) { + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Look for comment lines starting with # + if strings.HasPrefix(line, "#") { + // Remove the # and trim whitespace + commentContent := strings.TrimSpace(line[1:]) + // Check if it's a FileName directive + if strings.HasPrefix(commentContent, "FileName=") { + fileName := strings.TrimSpace(commentContent[9:]) // Remove "FileName=" + if fileName == "" { + return "", fmt.Errorf("FileName comment found but no filename specified") + } + // Validate filename (basic validation - no path separators, no extensions) + if strings.ContainsAny(fileName, "/\\") { + return "", fmt.Errorf("FileName '%s' cannot contain path separators", fileName) + } + if strings.Contains(fileName, ".") { + return "", fmt.Errorf("FileName '%s' should not include file extension", fileName) + } + return fileName, nil + } + } + } + return "", fmt.Errorf("missing required '# FileName=' comment at the beginning of quadlet section") +} + +// detectQuadletType analyzes the content of a quadlet section to determine its type +// Returns the appropriate file extension (.container, .volume, .network, etc.) +func detectQuadletType(content string) (string, error) { + // Look for section headers like [Container], [Volume], [Network], etc. + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + sectionName := strings.ToLower(strings.Trim(line, "[]")) + switch sectionName { + case "container": + return ".container", nil + case "volume": + return ".volume", nil + case "network": + return ".network", nil + case "kube": + return ".kube", nil + case "image": + return ".image", nil + case "build": + return ".build", nil + case "pod": + return ".pod", nil + } + } + } + return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])") +} + +// isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter +// The delimiter must be on its own line (possibly with whitespace) +func isMultiQuadletFile(filePath string) (bool, error) { + content, err := os.ReadFile(filePath) + if err != nil { + return false, err + } + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "---" { + return true, nil + } + } + return false, nil +} + // buildAppMap scans the given directory for files that start with '.' // and end with '.app', reads their contents (one filename per line), and // returns a map where each filename maps to the .app file that contains it. diff --git a/test/system/254-podman-quadlet-multi.bats b/test/system/254-podman-quadlet-multi.bats new file mode 100644 index 00000000000..e1d785a3a5e --- /dev/null +++ b/test/system/254-podman-quadlet-multi.bats @@ -0,0 +1,182 @@ +#!/usr/bin/env bats -*- bats -*- +# +# Tests for podman quadlet install with multi-quadlet files +# + +load helpers +load helpers.systemd + +function setup() { + skip_if_remote "podman quadlet is not implemented for remote setup yet" + skip_if_rootless_cgroupsv1 "Can't use --cgroups=split w/ CGv1 (issue 17456, wontfix)" + skip_if_journald_unavailable "Needed for RHEL. FIXME: we might be able to re-enable a subset of tests." + + basic_setup +} + +function teardown() { + systemctl daemon-reload + basic_teardown +} + +# Helper function to get the systemd install directory based on rootless/root mode +function get_quadlet_install_dir() { + if is_rootless; then + # For rootless: $XDG_CONFIG_HOME/containers/systemd or ~/.config/containers/systemd + local config_home=${XDG_CONFIG_HOME:-$HOME/.config} + echo "$config_home/containers/systemd" + else + # For root: /etc/containers/systemd + echo "/etc/containers/systemd" + fi +} + +@test "quadlet verb - install multi-quadlet file" { + # Determine the install directory path based on rootless/root + local install_dir=$(get_quadlet_install_dir) + + # Create a multi-quadlet file + local multi_quadlet_file=$PODMAN_TMPDIR/webapp.quadlets + cat > $multi_quadlet_file < $multi_quadlet_file < $multi_quadlet_file < Date: Mon, 3 Nov 2025 13:00:09 -0800 Subject: [PATCH 2/2] 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 --- .../markdown/podman-quadlet-install.1.md | 8 +- pkg/domain/infra/abi/quadlet.go | 86 ++--- test/system/254-podman-quadlet-multi.bats | 326 ++++++++++++++---- 3 files changed, 294 insertions(+), 126 deletions(-) diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md index 5410f25ec46..eadb1ebb431 100644 --- a/docs/source/markdown/podman-quadlet-install.1.md +++ b/docs/source/markdown/podman-quadlet-install.1.md @@ -16,9 +16,9 @@ This command allows you to: * Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation ( example a config file for a quadlet container ). - * 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=` comment to specify the name for that quadlet. + * 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=` comment to specify the name for that quadlet, extension of `FileName` is not required as it will be generated by parser internally. -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. +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. Note: In case user wants to install Quadlet application then first path should be the path to application directory. @@ -69,15 +69,11 @@ $ cat webapp.quadlets Image=nginx:latest ContainerName=web-server PublishPort=8080:80 - --- - # FileName=app-storage [Volume] Label=app=webapp - --- - # FileName=app-network [Network] Subnet=10.0.0.0/24 diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go index 6140c8e83fb..cfaeed86fec 100644 --- a/pkg/domain/infra/abi/quadlet.go +++ b/pkg/domain/infra/abi/quadlet.go @@ -164,7 +164,15 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str for _, toInstall := range paths { validateQuadletFile := false if assetFile == "" { - assetFile = "." + filepath.Base(toInstall) + ".asset" + // Check if this is a .quadlets file - if so, treat as an app + ext := filepath.Ext(toInstall) + if ext == ".quadlets" { + // For .quadlets files, use .app extension to group all quadlets as one application + baseName := strings.TrimSuffix(filepath.Base(toInstall), filepath.Ext(toInstall)) + assetFile = "." + baseName + ".app" + } else { + assetFile = "." + filepath.Base(toInstall) + ".asset" + } validateQuadletFile = true } switch { @@ -212,36 +220,19 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str // Check if this file has a supported extension or is a .quadlets file hasValidExt := systemdquadlet.IsExtSupported(toInstall) - ext := strings.ToLower(filepath.Ext(toInstall)) - isQuadletsFile := ext == ".quadlets" - - // Only check for multi-quadlet content if it's a .quadlets file - var isMulti bool - if isQuadletsFile { - var err error - isMulti, err = isMultiQuadletFile(toInstall) - if err != nil { - installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to check if file is multi-quadlet: %w", err) - continue - } - // For .quadlets files, always treat as multi-quadlet (even single quadlets) - isMulti = true - } + isQuadletsFile := filepath.Ext(toInstall) == ".quadlets" // Handle files with unsupported extensions that are not .quadlets files if !hasValidExt && !isQuadletsFile { // If we're installing as part of an app (assetFile is set), allow non-quadlet files as assets - if assetFile != "" { - // This is part of an app installation, allow non-quadlet files as assets - // Don't validate as quadlet file (validateQuadletFile will be false) - } else { + if assetFile == "" { // Standalone files with unsupported extensions are not allowed installReport.QuadletErrors[toInstall] = fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(toInstall)) continue } } - if isMulti { + if isQuadletsFile { // Parse the multi-quadlet file quadlets, err := parseMultiQuadletFile(toInstall) if err != nil { @@ -257,11 +248,11 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file for quadlet section %s: %w", quadlet.name, err) continue } - + defer os.Remove(tmpFile.Name()) // Write the quadlet content to the temporary file _, err = tmpFile.WriteString(quadlet.content) + tmpFile.Close() if err != nil { - tmpFile.Close() os.Remove(tmpFile.Name()) installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to write quadlet section %s to temporary file: %w", quadlet.name, err) continue @@ -385,6 +376,14 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins if err != nil { return "", fmt.Errorf("error while writing non-quadlet filename: %w", err) } + } else if strings.HasSuffix(assetFile, ".app") { + // For quadlet files that are part of an application (indicated by .app extension), + // also write the quadlet filename to the .app file for proper application tracking + quadletName := filepath.Base(finalPath) + err := appendStringToFile(filepath.Join(installDir, assetFile), quadletName) + if err != nil { + return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err) + } } return finalPath, nil } @@ -430,11 +429,8 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) { currentSection.Reset() } } else { - // Add line to current section - if currentSection.Len() > 0 { - currentSection.WriteString("\n") - } currentSection.WriteString(line) + currentSection.WriteString("\n") } } @@ -529,45 +525,15 @@ func detectQuadletType(content string) (string, error) { line = strings.TrimSpace(line) if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { sectionName := strings.ToLower(strings.Trim(line, "[]")) - switch sectionName { - case "container": - return ".container", nil - case "volume": - return ".volume", nil - case "network": - return ".network", nil - case "kube": - return ".kube", nil - case "image": - return ".image", nil - case "build": - return ".build", nil - case "pod": - return ".pod", nil + expected := "." + sectionName + if systemdquadlet.IsExtSupported("a" + expected) { + return expected, nil } } } return "", fmt.Errorf("no recognized quadlet section found (expected [Container], [Volume], [Network], [Kube], [Image], [Build], or [Pod])") } -// isMultiQuadletFile checks if a file contains multiple quadlets by looking for "---" delimiter -// The delimiter must be on its own line (possibly with whitespace) -func isMultiQuadletFile(filePath string) (bool, error) { - content, err := os.ReadFile(filePath) - if err != nil { - return false, err - } - - lines := strings.Split(string(content), "\n") - for _, line := range lines { - trimmed := strings.TrimSpace(line) - if trimmed == "---" { - return true, nil - } - } - return false, nil -} - // buildAppMap scans the given directory for files that start with '.' // and end with '.app', reads their contents (one filename per line), and // returns a map where each filename maps to the .app file that contains it. diff --git a/test/system/254-podman-quadlet-multi.bats b/test/system/254-podman-quadlet-multi.bats index e1d785a3a5e..b601d2baf5d 100644 --- a/test/system/254-podman-quadlet-multi.bats +++ b/test/system/254-podman-quadlet-multi.bats @@ -35,113 +35,175 @@ function get_quadlet_install_dir() { # Determine the install directory path based on rootless/root local install_dir=$(get_quadlet_install_dir) - # Create a multi-quadlet file - local multi_quadlet_file=$PODMAN_TMPDIR/webapp.quadlets + # Generate random names for parallelism + local app_name="webapp_$(random_string)" + local container_name="webserver_$(random_string)" + local volume_name="appstorage_$(random_string)" + local network_name="appnetwork_$(random_string)" + + # Create a multi-quadlet file with additional systemd sections + local multi_quadlet_file=$PODMAN_TMPDIR/${app_name}.quadlets cat > $multi_quadlet_file < $multi_quadlet_file < $multi_quadlet_file < "$app_dir/${frontend_name}.container" < "$app_dir/${data_name}.volume" < "$app_dir/backend_$(random_string).quadlets" < "$app_dir/app.conf" <