diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md index d3e95a349c6..eadb1ebb431 100644 --- a/docs/source/markdown/podman-quadlet-install.1.md +++ b/docs/source/markdown/podman-quadlet-install.1.md @@ -16,7 +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 ). -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. + * 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. 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. @@ -59,5 +61,30 @@ $ 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..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 { @@ -209,13 +217,73 @@ 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) + 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 == "" { + // 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 isQuadletsFile { + // 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 + } + defer os.Remove(tmpFile.Name()) + // Write the quadlet content to the temporary file + _, err = tmpFile.WriteString(quadlet.content) + tmpFile.Close() + if err != nil { + 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 } } @@ -308,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 } @@ -325,6 +401,139 @@ 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 { + currentSection.WriteString(line) + currentSection.WriteString("\n") + } + } + + // 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, "[]")) + 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])") +} + // 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..b601d2baf5d --- /dev/null +++ b/test/system/254-podman-quadlet-multi.bats @@ -0,0 +1,388 @@ +#!/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) + + # 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" <