From c22c3271bb6f5b25c28b8bb3f06085378c644598 Mon Sep 17 00:00:00 2001 From: flouthoc Date: Mon, 3 Nov 2025 13:00:09 -0800 Subject: [PATCH] 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 | 130 ++----- test/system/254-podman-quadlet-multi.bats | 336 +++++++++++++++--- 3 files changed, 318 insertions(+), 156 deletions(-) diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md index 5410f25ec4..a3986e0a0f 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. -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 6140c8e83f..d1f12adcab 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,17 @@ 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 { - // 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 we're installing as part of an app (assetFile is set), allow non-quadlet files as assets + // Standalone files with unsupported extensions are not allowed + if !hasValidExt && !isQuadletsFile && assetFile == "" { + 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,31 +246,25 @@ 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 } - 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) + installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", destName, 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) + sectionKey := fmt.Sprintf("%s#%s", toInstall, destName) installReport.InstalledQuadlets[sectionKey] = installedPath } } else { @@ -385,6 +368,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 +421,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") } } @@ -443,9 +431,6 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) { 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)) @@ -462,19 +447,11 @@ func parseMultiQuadletFile(filePath string) ([]quadletSection, error) { 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 + fileName, err := extractFileNameFromSection(section) + if err != nil { + return nil, fmt.Errorf("section %d: %w", i+1, err) } + name := fileName quadlets = append(quadlets, quadletSection{ content: section, @@ -506,13 +483,10 @@ func extractFileNameFromSection(content string) (string, error) { if fileName == "" { return "", fmt.Errorf("FileName comment found but no filename specified") } - // Validate filename (basic validation - no path separators, no extensions) + // Validate filename (basic validation - no path separators) 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 } } @@ -529,45 +503,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 e1d785a3a5..13f3be15a8 100644 --- a/test/system/254-podman-quadlet-multi.bats +++ b/test/system/254-podman-quadlet-multi.bats @@ -15,6 +15,8 @@ function setup() { } function teardown() { + # remove any remaining quadlets from tests + run_podman quadlet rm --all -f systemctl daemon-reload basic_teardown } @@ -35,113 +37,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 < $multi_quadlet_file < "$app_dir/${frontend_name}.container" < "$app_dir/${data_name}.volume" < "$app_dir/backend_$(random_string).quadlets" < "$app_dir/app.conf" <