From 6079157e37e04253f13df2752a8ab15bab3826ed Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:24:50 +0200 Subject: [PATCH] feat: update podman quadlet sub-command Fixes: #28118 Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> --- cmd/podman/quadlet/install.go | 2 + cmd/podman/quadlet/remove.go | 6 +- cmd/quadlet/main.go | 2 +- .../markdown/podman-quadlet-install.1.md | 21 +- docs/source/markdown/podman-quadlet-rm.1.md | 10 +- pkg/api/handlers/libpod/quadlets.go | 17 +- pkg/domain/entities/quadlet.go | 4 + pkg/domain/infra/abi/quadlet.go | 640 ++++-------------- pkg/domain/infra/abi/quadlet_utils.go | 302 +++++++++ pkg/domain/infra/abi/quadlet_utils_test.go | 91 +++ pkg/systemd/quadlet/unitdirs.go | 13 +- pkg/systemd/quadlet/unitdirs_test.go | 22 +- test/apiv2/36-quadlets.at | 86 ++- test/system/253-podman-quadlet.bats | 37 +- test/system/254-podman-quadlet-multi.bats | 87 +-- 15 files changed, 685 insertions(+), 655 deletions(-) create mode 100644 pkg/domain/infra/abi/quadlet_utils.go create mode 100644 pkg/domain/infra/abi/quadlet_utils_test.go diff --git a/cmd/podman/quadlet/install.go b/cmd/podman/quadlet/install.go index 13f58ecb47f..c4ec2b7e1d6 100644 --- a/cmd/podman/quadlet/install.go +++ b/cmd/podman/quadlet/install.go @@ -37,6 +37,8 @@ func installFlags(cmd *cobra.Command) { flags := cmd.Flags() flags.BoolVar(&installOptions.ReloadSystemd, "reload-systemd", true, "Reload systemd after installing Quadlets") flags.BoolVarP(&installOptions.Replace, "replace", "r", false, "Replace the installation even if the quadlet already exists") + flags.StringVar(&installOptions.Application, "application", "", "Group quadlets and associated file in a directory named after the application") + _ = quadletInstallCmd.RegisterFlagCompletionFunc("application", completion.AutocompleteNone) } func init() { diff --git a/cmd/podman/quadlet/remove.go b/cmd/podman/quadlet/remove.go index 27378817397..901111643d2 100644 --- a/cmd/podman/quadlet/remove.go +++ b/cmd/podman/quadlet/remove.go @@ -15,14 +15,15 @@ var ( quadletRmDescription = `Remove one or more installed Quadlets from the current user` quadletRmCmd = &cobra.Command{ - Use: "rm [options] QUADLET [QUADLET...]", + Use: "rm [options] [QUADLET|APPLICATION...]", Short: "Remove Quadlets", Long: quadletRmDescription, RunE: rm, ValidArgsFunction: common.AutocompleteQuadlets, Example: `podman quadlet rm test.container podman quadlet rm --force mysql.container -podman quadlet rm --all --reload-systemd=false`, +podman quadlet rm --all --reload-systemd=false +podman quadlet rm --recursive djangoapp`, } removeOptions entities.QuadletRemoveOptions @@ -35,6 +36,7 @@ func rmFlags(cmd *cobra.Command) { flags.BoolVarP(&removeOptions.All, "all", "a", false, "Remove all Quadlets for the current user") flags.BoolVarP(&removeOptions.Ignore, "ignore", "i", false, "Do not error for Quadlets that do not exist") flags.BoolVar(&removeOptions.ReloadSystemd, "reload-systemd", true, "Reload systemd after removal") + flags.BoolVar(&removeOptions.Recursive, "recursive", false, "Remove all Quadlets belonging to the specified application and its directory") } func init() { diff --git a/cmd/quadlet/main.go b/cmd/quadlet/main.go index 7b8e8fc2c73..fe78d1f92dd 100644 --- a/cmd/quadlet/main.go +++ b/cmd/quadlet/main.go @@ -478,7 +478,7 @@ func process() bool { Debugf("Starting quadlet-generator, output to: %s", outputPath) } - sourcePathsMap := quadlet.GetUnitDirs(isUserFlag) + sourcePathsMap := quadlet.GetUnitDirs(isUserFlag, true) var units []*parser.UnitFile for _, d := range sourcePathsMap { diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md index cac9630e48e..9035d8c2f7d 100644 --- a/docs/source/markdown/podman-quadlet-install.1.md +++ b/docs/source/markdown/podman-quadlet-install.1.md @@ -14,16 +14,29 @@ This command allows you to: * Install a single Quadlet file, optionally followed by additional non-Quadlet files. - * Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation ( example a config file for a quadlet container ). + * Specify a directory containing multiple Quadlet files and other non-Quadlet files for installation (for example a config file for a quadlet container). * 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. Similarly, when multiple quadlets are installed from a single `.quadlets` file, they are all considered part of the same 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. +Note: In case user wants to install Quadlet application then first path should +be the path to application directory. ## OPTIONS +#### **--application**=*string* + +You can specify an application name, all files will be installed under a +directory with the application name. The application name is required when +specifying a directory path. An application name can't have a quadlet extension +as suffix. For example `foo.container` isn't a valid application name. + #### **--reload-systemd** Reload systemd after installing Quadlets (default true). @@ -48,7 +61,7 @@ $ podman quadlet install test-service-quadlet.container Install quadlet from a dir. ``` -$ podman quadlet install /home/user/work/quadlet-app/ +$ podman quadlet install --application=foo /home/user/work/quadlet-app/ /home/user/.config/containers/systemd/myquadlet1.container /home/user/.config/containers/systemd/myquadlet2.container /install/path/myquadlet1.container diff --git a/docs/source/markdown/podman-quadlet-rm.1.md b/docs/source/markdown/podman-quadlet-rm.1.md index a9195f4da7d..a7ea6159410 100644 --- a/docs/source/markdown/podman-quadlet-rm.1.md +++ b/docs/source/markdown/podman-quadlet-rm.1.md @@ -4,7 +4,7 @@ podman\-quadlet\-rm - Removes an installed quadlet ## SYNOPSIS -**podman quadlet rm** [*options*] *quadlet* [*quadlet*]... +**podman quadlet rm** [*options*] *quadlet|application* [*quadlet|application*]... ## DESCRIPTION @@ -29,6 +29,10 @@ Remove running quadlets. Do not error for Quadlets that do not exist. +#### **--recursive** + +Required when removing applications (default false). + #### **--reload-systemd** Reload systemd after removing Quadlets (default true). @@ -40,6 +44,10 @@ of this flag to `false`. ``` $ podman quadlet rm myquadlet.container myquadlet.container +$ podman quadlet rm --recursive myapp +web.container +data.container +data.volume ``` ## SEE ALSO diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index a54054ea15e..89aa448dfb5 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -180,10 +180,10 @@ func InstallQuadlets(w http.ResponseWriter, r *http.Request) { // Parse query parameters query := struct { - Replace bool `schema:"replace"` - ReloadSystemd bool `schema:"reload-systemd"` + Replace bool `schema:"replace"` + ReloadSystemd bool `schema:"reload-systemd"` + Application string `schema:"application"` }{ - Replace: false, ReloadSystemd: true, // Default to true like CLI } @@ -227,11 +227,7 @@ func InstallQuadlets(w http.ResponseWriter, r *http.Request) { countQuadletFiles++ } } - switch { - case countQuadletFiles > 1: - utils.Error(w, http.StatusBadRequest, fmt.Errorf("only a single quadlet file is allowed per request")) - return - case countQuadletFiles == 0: + if countQuadletFiles == 0 { utils.Error(w, http.StatusBadRequest, fmt.Errorf("no quadlet files found in request")) return } @@ -239,6 +235,7 @@ func InstallQuadlets(w http.ResponseWriter, r *http.Request) { containerEngine := abi.ContainerEngine{Libpod: runtime} installOptions := entities.QuadletInstallOptions{ Replace: query.Replace, + Application: query.Application, ReloadSystemd: query.ReloadSystemd, } @@ -268,6 +265,7 @@ func RemoveQuadlet(w http.ResponseWriter, r *http.Request) { Force bool `schema:"force"` Ignore bool `schema:"ignore"` ReloadSystemd bool `schema:"reload-systemd"` + Recursive bool `schema:"recursive"` }{ ReloadSystemd: true, // Default to true like CLI } @@ -288,6 +286,7 @@ func RemoveQuadlet(w http.ResponseWriter, r *http.Request) { Force: query.Force, Ignore: query.Ignore, ReloadSystemd: query.ReloadSystemd, + Recursive: query.Recursive, } removeReport, err := containerEngine.QuadletRemove(r.Context(), []string{name}, removeOptions) @@ -324,6 +323,7 @@ func RemoveQuadlets(w http.ResponseWriter, r *http.Request) { Force bool `schema:"force"` Ignore bool `schema:"ignore"` ReloadSystemd bool `schema:"reload-systemd"` + Recursive bool `schema:"recursive"` Quadlets []string `schema:"quadlets"` }{ ReloadSystemd: true, // Default to true like CLI @@ -352,6 +352,7 @@ func RemoveQuadlets(w http.ResponseWriter, r *http.Request) { All: query.All, Ignore: query.Ignore, ReloadSystemd: query.ReloadSystemd, + Recursive: query.Recursive, } removeReport, err := containerEngine.QuadletRemove(r.Context(), query.Quadlets, removeOptions) diff --git a/pkg/domain/entities/quadlet.go b/pkg/domain/entities/quadlet.go index def11b17c15..94013e373eb 100644 --- a/pkg/domain/entities/quadlet.go +++ b/pkg/domain/entities/quadlet.go @@ -6,6 +6,8 @@ type QuadletInstallOptions struct { ReloadSystemd bool // Replace the installation even if the quadlet already exists Replace bool + // The application to install the quadlet to + Application string } // QuadletInstallReport contains the output of the `quadlet install` command @@ -56,6 +58,8 @@ type QuadletRemoveOptions struct { Ignore bool // ReloadSystemd determines whether systemd will be reloaded after the Quadlet is removed. ReloadSystemd bool + // You can specify recursive when targeting an application + Recursive bool } // QuadletRemoveReport contains the results of an operation to remove obe or more quadlets diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go index 4446ebaa090..47b4829f1a5 100644 --- a/pkg/domain/infra/abi/quadlet.go +++ b/pkg/domain/infra/abi/quadlet.go @@ -3,7 +3,6 @@ package abi import ( - "bufio" "context" "errors" "fmt" @@ -14,7 +13,6 @@ import ( "os" "path" "path/filepath" - "slices" "strings" "github.com/sirupsen/logrus" @@ -23,76 +21,10 @@ import ( "go.podman.io/podman/v6/pkg/domain/entities" "go.podman.io/podman/v6/pkg/rootless" "go.podman.io/podman/v6/pkg/systemd" - "go.podman.io/podman/v6/pkg/systemd/parser" systemdquadlet "go.podman.io/podman/v6/pkg/systemd/quadlet" - "go.podman.io/podman/v6/pkg/util" "go.podman.io/storage/pkg/fileutils" ) -// deleteAsset reads ..asset, deletes listed files, then deletes the asset file -func deleteAsset(name string) error { - assetFilename := fmt.Sprintf(".%s.asset", name) - - installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()) - assetFilePath := filepath.Join(installDir, assetFilename) - result, err := getAssetListFromFile(assetFilePath) - if err != nil { - return fmt.Errorf("unable to get list of files to delete: %w", err) - } - for _, entry := range result { - err = os.Remove(filepath.Join(installDir, entry)) - if err != nil { - return fmt.Errorf("unable to delete %s: %w", filepath.Join(installDir, entry), err) - } - } - err = os.Remove(assetFilePath) - if err != nil { - return fmt.Errorf("unable to delete %s: %w", assetFilePath, err) - } - return err -} - -// readLinesFromFile reads lines from a file and calls the provided callback for each non-empty line. -// It handles file opening, scanning, trimming whitespace, and error checking. -func readLinesFromFile(filePath string, callback func(line string) error) error { - file, err := os.Open(filePath) - if err != nil { - return fmt.Errorf("could not open file: %w", err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - if err := callback(line); err != nil { - return err - } - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading file: %w", err) - } - return nil -} - -func getAssetListFromFile(path string) ([]string, error) { - var result []string - err := readLinesFromFile(path, func(line string) error { - if strings.Contains(line, "/") { - logrus.Warnf("Unexpected file line %q, expected name but got path components", line) - return nil - } - result = append(result, line) - return nil - }) - if err != nil { - return result, fmt.Errorf("error reading asset file: %w", err) - } - return result, nil -} - // Install one or more Quadlet files func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []string, options entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) { // Is systemd available to the current user? @@ -125,6 +57,17 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str } installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()) + + if len(options.Application) > 0 { + // Prevent path traversal by validating the user input "Application" + err := validateApplicationName(installDir, options.Application) + if err != nil { + return nil, fmt.Errorf("invalid application name: %w", err) + } + + installDir = filepath.Join(installDir, options.Application) + } + logrus.Debugf("Going to install Quadlet to directory %s", installDir) if err := os.MkdirAll(installDir, 0o755); err != nil { @@ -136,7 +79,6 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str QuadletErrors: make(map[string]error), } - assetFile := "" paths := pathsOrURLs if len(pathsOrURLs) > 0 && !strings.HasPrefix(pathsOrURLs[0], "http://") && !strings.HasPrefix(pathsOrURLs[0], "https://") { // Check if first path is dir, this is an APP @@ -145,6 +87,10 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str return nil, fmt.Errorf("unable to stat Quadlet path %s: %w", pathsOrURLs[0], err) } if info.IsDir() { + if len(options.Application) == 0 { + return nil, fmt.Errorf("application name cannot be empty when installing from directory") + } + // If it's a directory, then read all files and add it to paths entries, err := os.ReadDir(pathsOrURLs[0]) if err != nil { @@ -156,30 +102,14 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str } redoPaths = append(redoPaths, pathsOrURLs[1:]...) paths = redoPaths - // treat all file in this session as part of one app. - assetFile = "." + filepath.Base(pathsOrURLs[0]) + ".app" + } else if !systemdquadlet.IsExtSupported(pathsOrURLs[0]) && + filepath.Ext(pathsOrURLs[0]) != ".quadlets" { + return nil, fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(pathsOrURLs[0])) } } // Loop over all given URLs for _, toInstall := range paths { - validateQuadletFile := false - if assetFile == "" { - // 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 { - if systemdquadlet.IsExtSupported(toInstall) { - assetFile = "." + filepath.Base(toInstall) + ".app" - } else { - assetFile = "." + filepath.Base(toInstall) + ".asset" - } - } - validateQuadletFile = true - } switch { case strings.HasPrefix(toInstall, "http://") || strings.HasPrefix(toInstall, "https://"): r, err := http.Get(toInstall) @@ -210,7 +140,7 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str installReport.QuadletErrors[toInstall] = fmt.Errorf("populating temporary file: %w", err) continue } - installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), quadletFileName, installDir, assetFile, validateQuadletFile, options.Replace) + installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), quadletFileName, installDir, options.Replace) if err != nil { installReport.QuadletErrors[toInstall] = err continue @@ -224,17 +154,8 @@ 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) isQuadletsFile := filepath.Ext(toInstall) == ".quadlets" - // Handle files with unsupported extensions that are not .quadlets files - // 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 isQuadletsFile { // Parse the multi-quadlet file quadlets, err := parseMultiQuadletFile(toInstall) @@ -262,7 +183,7 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str // 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) + installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), destName, installDir, options.Replace) if err != nil { installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to install quadlet section %s: %w", destName, err) continue @@ -274,7 +195,7 @@ func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []str } } 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) + installedPath, err := ic.installQuadlet(ctx, toInstall, filepath.Base(toInstall), installDir, options.Replace) if err != nil { installReport.QuadletErrors[toInstall] = err continue @@ -319,14 +240,42 @@ func getFileName(resp *http.Response, fileURL string) (string, error) { // Perform some minimal validation, but not much. // We can't know about a lot of problems without running the Quadlet binary, which we // only want to do once. -func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, installDir, assetFile string, isQuadletFile, replace bool) (string, error) { +func (ic *ContainerEngine) installQuadlet(ctx context.Context, path, destName, installDir string, replace bool) (string, error) { + select { + case <-ctx.Done(): + return "", fmt.Errorf("context cancelled: %w", ctx.Err()) + default: + } + // First, validate that the source path exists and is a file stat, err := os.Stat(path) if err != nil { return "", fmt.Errorf("quadlet to install %q does not exist or cannot be read: %w", path, err) } if stat.IsDir() { - return "", fmt.Errorf("quadlet to install %q is not a file", path) + dirs, err := os.ReadDir(path) + if err != nil { + return "", err + } + + for _, d := range dirs { + nInstallDir := filepath.Join(installDir, destName) + err := os.MkdirAll(nInstallDir, 0o755) + if err != nil { + return "", err + } + + _, err = ic.installQuadlet( + ctx, + filepath.Join(path, d.Name()), // path + d.Name(), // destName + nInstallDir, // installDir + replace) + if err != nil { + return "", err + } + } + return path, nil } finalPath := filepath.Join(installDir, filepath.Base(filepath.Clean(path))) @@ -334,11 +283,6 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins finalPath = filepath.Join(installDir, destName) } - // Validate extension is valid - if isQuadletFile && !systemdquadlet.IsExtSupported(finalPath) { - return "", fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(finalPath)) - } - var destFile *os.File var tempPath string @@ -397,46 +341,9 @@ func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, ins } tempPath = "" } - - if !isQuadletFile { - err := appendLineToFile(filepath.Join(installDir, assetFile), filepath.Base(filepath.Clean(path))) - if err != nil { - return "", fmt.Errorf("error while writing non-quadlet filename: %w", err) - } - } else if strings.HasSuffix(assetFile, ".app") { - quadletName := filepath.Base(finalPath) - err := appendLineToFile(filepath.Join(installDir, assetFile), quadletName) - if err != nil { - return "", fmt.Errorf("error while writing quadlet filename to app file: %w", err) - } - } return finalPath, nil } -// appendLineToFile appends the given text as a line to the specified file, -// ensuring it does not already exist (idempotency). -func appendLineToFile(path, text string) error { - content, err := os.ReadFile(path) - if err == nil { - for _, line := range strings.Split(string(content), "\n") { - if line == text { - return nil // Already exists, do nothing - } - } - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - - if _, err := f.WriteString(text + "\n"); err != nil { - return err - } - return nil -} - // quadletSection represents a single quadlet extracted from a multi-quadlet file type quadletSection struct { content string @@ -556,123 +463,7 @@ func detectQuadletType(content string) (string, error) { 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. -// Also returns a map where each `.app` points to a slice of strings containing -// all the files in that `.app`. -func buildAppMap(dir string) (map[string]string, map[string][]string, error) { - reverseMap := make(map[string]string) - appMap := make(map[string][]string) - - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - logrus.Warnf("Error descending into path %s: %v", path, err) - } - return filepath.SkipDir - } - info, err := d.Info() - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - logrus.Warnf("Error descending into path %s: %v", path, err) - } - return filepath.SkipDir - } - if !info.IsDir() { - name := info.Name() - if strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".app") { - err := readLinesFromFile(path, func(line string) error { - reverseMap[line] = name - appMap[name] = append(appMap[name], line) - return nil - }) - if err != nil { - return err - } - } - } - return nil - }) - if err != nil { - return nil, nil, err - } - return reverseMap, appMap, nil -} - -// Get the paths of all quadlets available to the current user -func getAllQuadletPaths() []string { - var quadletPaths []string - quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless()) - for _, dir := range quadletDirs { - dents, err := os.ReadDir(dir) - if err != nil { - if !errors.Is(err, fs.ErrNotExist) { - // This is perfectly normal, some quadlet directories aren't created by the package - logrus.Warnf("Cannot list Quadlet directory %s: %v", dir, err) - } - continue - } - logrus.Debugf("Checking for quadlets in %q", dir) - for _, dent := range dents { - if systemdquadlet.IsExtSupported(dent.Name()) && !dent.IsDir() { - logrus.Debugf("Found quadlet %q", dent.Name()) - quadletPaths = append(quadletPaths, filepath.Join(dir, dent.Name())) - } - } - } - return quadletPaths -} - -// getQuadletServiceNameAndUnit parses a Quadlet file and returns both the -// generated systemd service name and the parsed unit file. -func getQuadletServiceNameAndUnit(quadletPath string) (string, *parser.UnitFile, error) { - unit, err := parser.ParseUnitFile(quadletPath) - if err != nil { - return "", nil, fmt.Errorf("parsing Quadlet file %s: %w", quadletPath, err) - } - - serviceName, err := systemdquadlet.GetUnitServiceName(unit) - if err != nil { - return "", nil, fmt.Errorf("generating service name for Quadlet %s: %w", filepath.Base(quadletPath), err) - } - return serviceName + ".service", unit, nil -} - -// Generate systemd service name for a Quadlet from full path to the Quadlet file -func getQuadletServiceName(quadletPath string) (string, error) { - name, _, err := getQuadletServiceNameAndUnit(quadletPath) - return name, err -} - -type QuadletFilter func(q *entities.ListQuadlet) bool - -func generateQuadletFilter(filter string, filterValues []string) (func(q *entities.ListQuadlet) bool, error) { - switch filter { - case "name": - return func(q *entities.ListQuadlet) bool { - res := util.StringMatchRegexSlice(q.Name, filterValues) - return res - }, nil - case "status": - return func(q *entities.ListQuadlet) bool { - res := util.StringMatchRegexSlice(q.Status, filterValues) - return res - }, nil - case "pod": - return func(q *entities.ListQuadlet) bool { - return util.StringMatchRegexSlice(q.Pod, filterValues) - }, nil - default: - return nil, fmt.Errorf("%s is not a valid filter", filter) - } -} - func (ic *ContainerEngine) QuadletList(ctx context.Context, options entities.QuadletListOptions) ([]*entities.ListQuadlet, error) { - reverseMap, _, err := buildAppMap(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())) - if err != nil { - return nil, fmt.Errorf("unable to build app map: %w", err) - } // Is systemd available to the current user? // We cannot proceed if not. conn, err := systemd.ConnectToDBUS() @@ -681,101 +472,20 @@ func (ic *ContainerEngine) QuadletList(ctx context.Context, options entities.Qua } defer conn.Close() - quadletPaths := getAllQuadletPaths() - // Create filter functions - filterFuncs := make([]func(q *entities.ListQuadlet) bool, 0, len(options.Filters)) - filterMap := make(map[string][]string) - // TODO: Add filter for app names. - for _, f := range options.Filters { - fname, filter, hasFilter := strings.Cut(f, "=") - if !hasFilter { - return nil, fmt.Errorf("invalid filter %q", f) - } - filterMap[fname] = append(filterMap[fname], filter) - } - for fname, filter := range filterMap { - filterFunc, err := generateQuadletFilter(fname, filter) - if err != nil { - return nil, err - } - filterFuncs = append(filterFuncs, filterFunc) - } - - reports := make([]*entities.ListQuadlet, 0, len(quadletPaths)) - allServiceNames := make([]string, 0, len(quadletPaths)) - partialReports := make(map[string]entities.ListQuadlet) - - for _, path := range quadletPaths { - appName := "" - value, ok := reverseMap[filepath.Base(path)] - if ok { - appName = value - } - report := entities.ListQuadlet{ - Name: filepath.Base(path), - Path: path, - App: appName, - } - - serviceName, unit, err := getQuadletServiceNameAndUnit(path) - if err != nil { - report.Status = err.Error() - reports = append(reports, &report) - continue - } - if pod, ok := unit.Lookup(systemdquadlet.ContainerGroup, systemdquadlet.KeyPod); ok { - report.Pod = pod - } - partialReports[serviceName] = report - allServiceNames = append(allServiceNames, serviceName) - } - - // Get status of all systemd units with given names. - statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames) + filterFunc, err := generateQuadletFilters(options.Filters) if err != nil { - return nil, fmt.Errorf("querying systemd for unit status: %w", err) - } - if len(statuses) != len(allServiceNames) { - logrus.Warnf("Queried for %d services but received %d responses", len(allServiceNames), len(statuses)) - } - for _, unitStatus := range statuses { - report, ok := partialReports[unitStatus.Name] - if !ok { - logrus.Errorf("Unexpected unit returned by systemd - was not searching for %s", unitStatus.Name) - } - - logrus.Debugf("Unit %s has status %s %s %s", unitStatus.Name, unitStatus.LoadState, unitStatus.ActiveState, unitStatus.SubState) - report.UnitName = unitStatus.Name - - // Unit is not loaded - if unitStatus.LoadState != "loaded" { - report.Status = "Not loaded" - } else { - report.Status = fmt.Sprintf("%s/%s", unitStatus.ActiveState, unitStatus.SubState) - } - reports = append(reports, &report) - delete(partialReports, unitStatus.Name) + return nil, fmt.Errorf("cannot use filters: %w", err) } - // This should not happen. - // Systemd will give us output for everything we sent to them, even if it's not a valid unit. - // We can find them with LoadState, as we do above. - // Handle it anyways because it's easy enough to do. - for _, report := range partialReports { - report.Status = "Not loaded" - reports = append(reports, &report) + reports, err := getAllQuadlets(ctx, conn) + if err != nil { + return nil, fmt.Errorf("cannot get quadlets: %w", err) } finalReports := make([]*entities.ListQuadlet, 0, len(reports)) for _, report := range reports { - include := true - for _, filterFunc := range filterFuncs { - if !filterFunc(report) { - include = false - break - } - } + include := filterFunc(report) if include { finalReports = append(finalReports, report) } @@ -800,7 +510,7 @@ func getQuadletPathByName(name string) (string, error) { return "", fmt.Errorf("%q is not a supported quadlet file type", filepath.Ext(name)) } - quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless()) + quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless(), true) for _, dir := range quadletDirs { testPath := filepath.Join(dir, name) if _, err := os.Stat(testPath); err != nil { @@ -835,48 +545,6 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, Errors: make(map[string]error), Removed: []string{}, } - removeList := []string{} - reverseMap, appMap, err := buildAppMap(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())) - if err != nil { - return nil, fmt.Errorf("unable to build app map: %w", err) - } - expandQuadletList := []string{} - // Process all `.app` files in arguments, if `.app` file - // is found then expand it to its respective quadlet files - // and remove it from the processing list. - for _, quadlet := range quadlets { - // Most likely this is an app - if strings.HasPrefix(quadlet, ".") && strings.HasSuffix(quadlet, ".app") { - files, ok := appMap[quadlet] - // Add all files of this application in to-be removed list. - if ok { - for _, file := range files { - if !systemdquadlet.IsExtSupported(file) { - removeList = append(removeList, file) - } else { - expandQuadletList = append(expandQuadletList, file) - } - } - } - // also add .app file itself to the remove list so it can - // be cleaned after removal of all components in the list - if !slices.Contains(removeList, quadlet) { - removeList = append(removeList, quadlet) - } - } else { - expandQuadletList = append(expandQuadletList, quadlet) - } - } - quadlets = expandQuadletList - allQuadletPaths := make([]string, 0, len(quadlets)) - allServiceNames := make([]string, 0, len(quadlets)) - runningQuadlets := make([]string, 0, len(quadlets)) - serviceNameToQuadletName := make(map[string]string) - needReload := options.ReloadSystemd - - if len(quadlets) == 0 && !options.All { - return nil, errors.New("must provide at least 1 quadlet to remove") - } // Is systemd available to the current user? // We cannot proceed if not. @@ -886,141 +554,107 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, } defer conn.Close() - if options.All { - allQuadlets := getAllQuadletPaths() - quadlets = allQuadlets + // Get all units (aka Quadlets) + units, err := getAllQuadlets(ctx, conn) + if err != nil { + return nil, fmt.Errorf("cannot get quadlets: %w", err) } - // We are using index wise iteration here instead of `range` - // because we are modifying `quadlets` in this loop by appending - // more elements to it if needed, we cannot do this with `range`. - for i := 0; i < len(quadlets); i++ { - var err error - var quadletPath string - quadlet := quadlets[i] - if options.All { - quadletPath = quadlet - } else { - quadletPath, err = getQuadletPathByName(quadlet) + if len(quadlets) == 0 && !options.All { + return nil, errors.New("must provide at least 1 quadlet to remove") + } + + // Group units by application + // Map application -> quadlets + applications := make(map[string][]*entities.ListQuadlet) + for _, unit := range units { + if len(unit.App) > 0 { + applications[unit.App] = append(applications[unit.App], unit) } - if !options.All && err != nil { - // All implies Ignore, because the only reason we'd see an error here with all - // is if the quadlet was removed in a TOCTOU scenario. - if options.Ignore { - report.Removed = append(report.Removed, quadlet) - } else { - report.Errors[quadlet] = err + } + + if options.All { + // Add all units not part of an Application + for _, unit := range units { + if len(unit.App) == 0 { + quadlets = append(quadlets, unit.Name) } - continue } - value, ok := reverseMap[quadlet] - if ok { - // If this is part of app and we are cleaning entire .app - // make sure to add .app file itself to the removal list - // if it does not already exists. - if !slices.Contains(removeList, value) { - removeList = append(removeList, value) - } - appFilePath := filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), value) - filesToRemove, err := getAssetListFromFile(appFilePath) - if err != nil { - return nil, fmt.Errorf("unable to get list of files to remove: %w", err) - } - for _, entry := range filesToRemove { - if !systemdquadlet.IsExtSupported(entry) { - removeList = append(removeList, entry) - if !slices.Contains(removeList, value) { - // In the last also clean ..app file - removeList = append(removeList, value) - } - continue - } - if !slices.Contains(quadlets, entry) { - quadlets = append(quadlets, entry) - } + // Add all application if recursive is true + if options.Recursive { + for application := range applications { + quadlets = append(quadlets, application) } } - - allQuadletPaths = append(allQuadletPaths, quadletPath) - - serviceName, err := getQuadletServiceName(quadletPath) - if err != nil { - report.Errors[quadlet] = err - continue - } - - allServiceNames = append(allServiceNames, serviceName) - serviceNameToQuadletName[serviceName] = quadlet } - if len(allServiceNames) != 0 { - // Check if units are loaded into systemd, and further if they are running. - // If running and force is not set, error. - // If force is set, try and stop the unit. - statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames) - if err != nil { - return nil, fmt.Errorf("querying systemd for unit status: %w", err) - } - for _, unitStatus := range statuses { - quadletName := serviceNameToQuadletName[unitStatus.Name] + // Create a map filename -> quadlet + files := make(map[string]*entities.ListQuadlet, len(units)) + for _, unit := range units { + files[unit.Name] = unit + } - if unitStatus.LoadState != "loaded" { - // Nothing to do here if it doesn't exist in systemd + // Iterate over the list of quadlets to remove + for _, quadlet := range quadlets { + if systemdquadlet.IsExtSupported(quadlet) { + // deleting a quadlet file + if files[quadlet] == nil { + if options.Ignore { + report.Removed = append(report.Removed, quadlet) + } else { + report.Errors[quadlet] = fmt.Errorf("no such quadlet") + } continue } - needReload = options.ReloadSystemd - if unitStatus.ActiveState == "active" { - if !options.Force { - report.Errors[quadletName] = fmt.Errorf("quadlet %s is running and force is not set, refusing to remove: %w", quadletName, define.ErrQuadletRunning) - runningQuadlets = append(runningQuadlets, quadletName) - continue - } - logrus.Infof("Going to stop systemd unit %s (Quadlet %s)", unitStatus.Name, quadletName) - ch := make(chan string) - if _, err := conn.StopUnitContext(ctx, unitStatus.Name, "replace", ch); err != nil { - report.Errors[quadletName] = fmt.Errorf("stopping quadlet %s: %w", quadletName, err) - runningQuadlets = append(runningQuadlets, quadletName) - continue - } - logrus.Debugf("Waiting for systemd unit %s to stop", unitStatus.Name) - stopResult := <-ch - if stopResult != "done" && stopResult != "skipped" { - report.Errors[quadletName] = fmt.Errorf("unable to stop quadlet %s: %s", quadletName, stopResult) - runningQuadlets = append(runningQuadlets, quadletName) - continue - } + + err := removeQuadlet(ctx, conn, files[quadlet], options.Force) + if err != nil { + report.Errors[quadlet] = err + } else { + report.Removed = append(report.Removed, files[quadlet].Name) + } + } else { + // delete an application + if len(applications[quadlet]) == 0 { + return nil, fmt.Errorf("no such application %q", quadlet) } - } - } - // Remove the actual files behind the quadlets - if len(allQuadletPaths) != 0 { - for _, path := range allQuadletPaths { - var errAsset error - quadletName := filepath.Base(path) - errAsset = deleteAsset(quadletName) - if slices.Contains(runningQuadlets, quadletName) { - continue + if !options.Recursive { + return nil, fmt.Errorf("refusing to remove application %q: recursive option is not set", quadlet) } - if err := os.Remove(path); err != nil { - if !errors.Is(err, fs.ErrNotExist) { - reportErr := fmt.Errorf("removing quadlet %s: %w", quadletName, err) - if errAsset != nil { - reportErr = errors.Join(reportErr, errAsset) - } - report.Errors[quadletName] = reportErr - continue + + removeFailed := false + for _, unit := range applications[quadlet] { + err := removeQuadlet(ctx, conn, unit, options.Force) + if err != nil { + removeFailed = true + // Use `quadlet` (i.e. the application name) as + // the `report.Errors` key. That's because function + // `RemoveQuadlet`, used by the Libpod API uses this + // map to look for error of a specific app removal. + report.Errors[quadlet] = err + } else { + report.Removed = append(report.Removed, unit.Name) } } - for _, entry := range removeList { - os.Remove(filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), entry)) + + // clean up application folder when no error + if !removeFailed { + appPath, err := getApplicationPath(quadlet) + if err != nil { + report.Errors[quadlet] = err + } else { + err = os.RemoveAll(appPath) + if err != nil { + report.Errors[quadlet] = err + } + } } - report.Removed = append(report.Removed, quadletName) } } // Reload systemd, if necessary/requested. - if needReload { + if options.ReloadSystemd { if err := conn.ReloadContext(ctx); err != nil { return &report, fmt.Errorf("reloading systemd: %w", err) } diff --git a/pkg/domain/infra/abi/quadlet_utils.go b/pkg/domain/infra/abi/quadlet_utils.go new file mode 100644 index 00000000000..9a4a4c81c9a --- /dev/null +++ b/pkg/domain/infra/abi/quadlet_utils.go @@ -0,0 +1,302 @@ +//go:build !remote && (linux || freebsd) + +package abi + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/coreos/go-systemd/v22/dbus" + "github.com/sirupsen/logrus" + "go.podman.io/podman/v6/libpod/define" + "go.podman.io/podman/v6/pkg/domain/entities" + "go.podman.io/podman/v6/pkg/rootless" + "go.podman.io/podman/v6/pkg/systemd/parser" + systemdquadlet "go.podman.io/podman/v6/pkg/systemd/quadlet" + "go.podman.io/podman/v6/pkg/util" +) + +type QuadletFilter func(q *entities.ListQuadlet) bool + +func generateQuadletFilter(filter string, filterValues []string) (QuadletFilter, error) { + switch filter { + case "name": + return func(q *entities.ListQuadlet) bool { + res := util.StringMatchRegexSlice(q.Name, filterValues) + return res + }, nil + case "status": + return func(q *entities.ListQuadlet) bool { + res := util.StringMatchRegexSlice(q.Status, filterValues) + return res + }, nil + case "app": + return func(q *entities.ListQuadlet) bool { + res := util.StringMatchRegexSlice(q.App, filterValues) + return res + }, nil + case "pod": + return func(q *entities.ListQuadlet) bool { + res := util.StringMatchRegexSlice(q.Pod, filterValues) + return res + }, nil + default: + return nil, fmt.Errorf("%s is not a valid filter", filter) + } +} + +func generateQuadletFilters(filters []string) (QuadletFilter, error) { + // Create filter functions + filterFuncs := make([]QuadletFilter, 0, len(filters)) + filterMap := make(map[string][]string) + for _, f := range filters { + fname, filter, hasFilter := strings.Cut(f, "=") + if !hasFilter { + return nil, fmt.Errorf("invalid filter %q", f) + } + filterMap[fname] = append(filterMap[fname], filter) + } + for fname, filter := range filterMap { + filterFunc, err := generateQuadletFilter(fname, filter) + if err != nil { + return nil, err + } + filterFuncs = append(filterFuncs, filterFunc) + } + + return func(q *entities.ListQuadlet) bool { + for _, filterFunc := range filterFuncs { + if !filterFunc(q) { + return false + } + } + return true + }, nil +} + +func getQuadlets(dir string) ([]string, error) { + reports := make([]string, 0) + + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + logrus.Warnf("Error descending into path %s: %v", path, err) + } + return filepath.SkipDir + } + + if d.IsDir() { + return nil + } + + if systemdquadlet.IsExtSupported(d.Name()) { + reports = append(reports, path) + } + + return nil + }) + if err != nil { + return nil, err + } + + return reports, nil +} + +func getAllQuadlets(ctx context.Context, conn *dbus.Conn) ([]*entities.ListQuadlet, error) { + reports := make([]*entities.ListQuadlet, 0) + + // Service name -> quadlet + partialReports := make(map[string]entities.ListQuadlet) + + // Get the root paths of all quadlets available to the current user + quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless(), false) + + allServiceNames := make([]string, 0) + + // for every quadlet dir, let's get the quadlets + for _, dir := range quadletDirs { + quadlets, err := getQuadlets(dir) + if err != nil { + return nil, err + } + + // for every quadlet we found, let's get the corresponding service name + for _, quadlet := range quadlets { + basename := filepath.Base(quadlet) + app := "" + + // Let's compare how "nested" the quadlet is + // if he is not at the root directory we use the first directory as app name + rel, err := filepath.Rel(dir, quadlet) + if err == nil && rel != basename { + app = strings.Split(rel, string(filepath.Separator))[0] + } + + report := entities.ListQuadlet{ + Name: basename, + Path: quadlet, + App: app, + } + + serviceName, unit, err := getQuadletServiceNameAndUnit(quadlet) + if err != nil { + report.Status = err.Error() + reports = append(reports, &report) + continue + } + + if pod, ok := unit.Lookup(systemdquadlet.ContainerGroup, systemdquadlet.KeyPod); ok { + report.Pod = pod + } + + allServiceNames = append(allServiceNames, serviceName) + partialReports[serviceName] = report + } + } + + // Get status of all systemd units with given names. + statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames) + if err != nil { + return nil, fmt.Errorf("querying systemd for unit status: %w", err) + } + if len(statuses) != len(allServiceNames) { + logrus.Warnf("Queried for %d services but received %d responses", len(allServiceNames), len(statuses)) + } + + for _, unitStatus := range statuses { + report, ok := partialReports[unitStatus.Name] + if !ok { + logrus.Errorf("Unexpected unit returned by systemd - was not searching for %s", unitStatus.Name) + } + logrus.Debugf("Unit %s has status %s %s %s", unitStatus.Name, unitStatus.LoadState, unitStatus.ActiveState, unitStatus.SubState) + report.UnitName = unitStatus.Name + + // Unit is not loaded + if unitStatus.LoadState != "loaded" { + report.Status = "Not loaded" + } else { + report.Status = fmt.Sprintf("%s/%s", unitStatus.ActiveState, unitStatus.SubState) + } + reports = append(reports, &report) + delete(partialReports, unitStatus.Name) + } + + // This should not happen. + // Systemd will give us output for everything we sent to them, even if it's not a valid unit. + // We can find them with LoadState, as we do above. + // Handle it anyways because it's easy enough to do. + for _, report := range partialReports { + report.Status = "Not loaded" + reports = append(reports, &report) + } + + return reports, nil +} + +func removeQuadlet(ctx context.Context, conn *dbus.Conn, quadlet *entities.ListQuadlet, force bool) error { + switch quadlet.Status { + case "Not loaded": + case "inactive/dead": + // Nothing to do here if it doesn't exist in systemd + break + case "active/running": + if !force { + return fmt.Errorf("quadlet %s is running and force is not set, refusing to remove: %w", quadlet.Name, define.ErrQuadletRunning) + } + logrus.Debugf("Going to stop systemd unit %s (Quadlet %s)", quadlet.Name, quadlet.Path) + + ch := make(chan string) + if _, err := conn.StopUnitContext(ctx, quadlet.UnitName, "replace", ch); err != nil { + return fmt.Errorf("stopping quadlet %s: %w", quadlet.Name, err) + } + + logrus.Debugf("Waiting for systemd unit %s to stop", quadlet.Name) + stopResult := <-ch + if stopResult != "done" && stopResult != "skipped" { + return fmt.Errorf("unable to stop quadlet %s: %s", quadlet.Name, stopResult) + } + } + + return os.Remove(quadlet.Path) +} + +// getQuadletServiceNameAndUnit parses a Quadlet file and returns both the +// generated systemd service name and the parsed unit file. +func getQuadletServiceNameAndUnit(quadletPath string) (string, *parser.UnitFile, error) { + unit, err := parser.ParseUnitFile(quadletPath) + if err != nil { + return "", nil, fmt.Errorf("parsing Quadlet file %s: %w", quadletPath, err) + } + + serviceName, err := systemdquadlet.GetUnitServiceName(unit) + if err != nil { + return "", nil, fmt.Errorf("generating service name for Quadlet %s: %w", filepath.Base(quadletPath), err) + } + return serviceName + ".service", unit, nil +} + +func getApplicationPath(app string) (string, error) { + // Get the root paths of all quadlets available to the current user + quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless(), false) + + // for every quadlet dir, let's get the quadlets + for _, dir := range quadletDirs { + // Avoiding using filepath.join with "app", as it may lead to + // path traversal attack + files, err := os.ReadDir(dir) + if errors.Is(err, fs.ErrNotExist) { + continue + } + + if err != nil { + return "", err + } + + for _, file := range files { + if file.IsDir() && file.Name() == app { + return filepath.Join(dir, file.Name()), nil + } + } + } + return "", fmt.Errorf("application %s not found", app) +} + +func validateApplicationName(baseDir string, application string) error { + if !filepath.IsAbs(baseDir) { + return fmt.Errorf("base directory must be an absolute path") + } + + if len(application) == 0 { + return fmt.Errorf("application name cannot be empty") + } + + if strings.Contains(application, string(os.PathSeparator)) { + return fmt.Errorf("invalid application name") + } + + if application == "." || application == ".." { + return fmt.Errorf("invalid application name") + } + + joined := filepath.Join(baseDir, application) + + fullAbs, err := filepath.Abs(joined) + if err != nil { + return err + } + + if !strings.HasPrefix(fullAbs, baseDir+string(os.PathSeparator)) { + return fmt.Errorf("invalid application name, must be a subdirectory of the base directory") + } + + if systemdquadlet.IsExtSupported(application) { + return fmt.Errorf("invalid application name, names can't end end with a quadlet extension") + } + + return nil +} diff --git a/pkg/domain/infra/abi/quadlet_utils_test.go b/pkg/domain/infra/abi/quadlet_utils_test.go new file mode 100644 index 00000000000..6951a7b6987 --- /dev/null +++ b/pkg/domain/infra/abi/quadlet_utils_test.go @@ -0,0 +1,91 @@ +//go:build !remote && (linux || freebsd) + +package abi + +import "testing" + +func TestValidateApplicationName(t *testing.T) { + tests := []struct { + name string + baseDir string + application string + wantErr bool + }{ + { + name: "valid simple name", + baseDir: "/opt/apps", + application: "myapp", + wantErr: false, + }, + { + name: "valid with dots", + baseDir: "/opt/apps", + application: "my.app.v1", + wantErr: false, + }, + { + name: "empty application", + baseDir: "/opt/apps", + application: "", + wantErr: true, + }, + { + name: "contains slash", + baseDir: "/opt/apps", + application: "foo/bar", + wantErr: true, + }, + { + name: "contains traversal", + baseDir: "/opt/apps", + application: "../etc", + wantErr: true, + }, + { + name: "is dot", + baseDir: "/opt/apps", + application: ".", + wantErr: true, + }, + { + name: "is dot dot", + baseDir: "/opt/apps", + application: "..", + wantErr: true, + }, + { + name: "base dir not absolute", + baseDir: "relative/path", + application: "myapp", + wantErr: true, + }, + { + name: "attempt to escape base dir (defense check)", + baseDir: "/opt/apps", + application: "../../etc", + wantErr: true, + }, + { + name: "prefix edge case (similar prefix but not subdir)", + baseDir: "/opt/app", + application: "sneaky", // results in /opt/app/sneaky (valid) + wantErr: false, + }, + { + name: "name ending with a quadlet extension is invalid", + baseDir: "/opt/apps", + application: "myapp.container", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateApplicationName(tt.baseDir, tt.application) + + if (err != nil) != tt.wantErr { + t.Fatalf("expected error=%v, got err=%v", tt.wantErr, err) + } + }) + } +} diff --git a/pkg/systemd/quadlet/unitdirs.go b/pkg/systemd/quadlet/unitdirs.go index eefd3069230..ee3b8932788 100644 --- a/pkg/systemd/quadlet/unitdirs.go +++ b/pkg/systemd/quadlet/unitdirs.go @@ -39,8 +39,8 @@ func GetInstallUnitDirPath(rootless bool) string { // For system generators these are in /usr/share/containers/systemd (for distro files) // and /etc/containers/systemd (for sysadmin files). // For user generators these can live in $XDG_RUNTIME_DIR/containers/systemd, /etc/containers/systemd/users, /etc/containers/systemd/users/$UID, /usr/share/containers/systemd/users/${UID}, /usr/share/containers/systemd/users/ and $XDG_CONFIG_HOME/containers/systemd -func GetUnitDirs(rootless bool) []string { - paths := NewSearchPaths() +func GetUnitDirs(rootless bool, recursive bool) []string { + paths := NewSearchPaths(recursive) // Allow overriding source dir, this is mainly for the CI tests if getDirsFromEnv(paths) { @@ -64,12 +64,15 @@ type searchPaths struct { sorted []string // map to store paths so we can quickly check if we saw them already and not loop in case of symlinks visitedDirs map[string]struct{} + + recursive bool } -func NewSearchPaths() *searchPaths { +func NewSearchPaths(recursive bool) *searchPaths { return &searchPaths{ sorted: make([]string, 0), visitedDirs: make(map[string]struct{}, 0), + recursive: recursive, } } @@ -122,6 +125,10 @@ func AppendSubPaths(paths *searchPaths, path string, isUserFlag bool, filterPtr // Add the current directory paths.Add(resolvedPath) + if !paths.recursive { + return + } + // Read the contents of the directory entries, err := os.ReadDir(resolvedPath) if err != nil { diff --git a/pkg/systemd/quadlet/unitdirs_test.go b/pkg/systemd/quadlet/unitdirs_test.go index ed8afce1e38..9176fb08fab 100644 --- a/pkg/systemd/quadlet/unitdirs_test.go +++ b/pkg/systemd/quadlet/unitdirs_test.go @@ -24,11 +24,11 @@ func TestUnitDirs(t *testing.T) { assert.NoError(t, err) if os.Getenv("_UNSHARED") != "true" { - unitDirs := GetUnitDirs(false) + unitDirs := GetUnitDirs(false, true) resolvedUnitDirAdminUser := ResolveUnitDirAdminUser() userLevelFilter := GetUserLevelFilter(resolvedUnitDirAdminUser) - rootfulPaths := NewSearchPaths() + rootfulPaths := NewSearchPaths(true) AppendSubPaths(rootfulPaths, UnitDirTemp, false, userLevelFilter) AppendSubPaths(rootfulPaths, UnitDirAdmin, false, userLevelFilter) AppendSubPaths(rootfulPaths, UnitDirDistro, false, userLevelFilter) @@ -37,7 +37,7 @@ func TestUnitDirs(t *testing.T) { configDir, err := os.UserConfigDir() assert.NoError(t, err) - rootlessPaths := NewSearchPaths() + rootlessPaths := NewSearchPaths(true) systemUserDirLevel := len(strings.Split(resolvedUnitDirAdminUser, string(os.PathSeparator))) nonNumericFilter := GetNonNumericFilter(resolvedUnitDirAdminUser, systemUserDirLevel) @@ -52,20 +52,20 @@ func TestUnitDirs(t *testing.T) { AppendSubPaths(rootlessPaths, filepath.Join(UnitDirDistro, "users"), true, nonNumericFilter) AppendSubPaths(rootlessPaths, filepath.Join(UnitDirDistro, "users", u.Uid), true, userLevelFilter) - unitDirs = GetUnitDirs(true) + unitDirs = GetUnitDirs(true, true) assert.Equal(t, rootlessPaths.GetSortedPaths(), unitDirs, "rootless unit dirs should match") // Test that relative path returns an empty list t.Setenv("QUADLET_UNIT_DIRS", "./relative/path") - unitDirs = GetUnitDirs(false) + unitDirs = GetUnitDirs(false, true) assert.Equal(t, []string{}, unitDirs) name := t.TempDir() t.Setenv("QUADLET_UNIT_DIRS", name) - unitDirs = GetUnitDirs(false) + unitDirs = GetUnitDirs(false, true) assert.Equal(t, []string{name}, unitDirs, "rootful should use environment variable") - unitDirs = GetUnitDirs(true) + unitDirs = GetUnitDirs(true, true) assert.Equal(t, []string{name}, unitDirs, "rootless should use environment variable") symLinkTestBaseDir := t.TempDir() @@ -80,7 +80,7 @@ func TestUnitDirs(t *testing.T) { err = os.Symlink(actualDir, symlink) assert.NoError(t, err) t.Setenv("QUADLET_UNIT_DIRS", symlink) - unitDirs = GetUnitDirs(true) + unitDirs = GetUnitDirs(true, true) assert.Equal(t, []string{actualDir, innerDir}, unitDirs, "directory resolution should follow symlink") // Make a more elborate test with the following structure: @@ -142,7 +142,7 @@ func TestUnitDirs(t *testing.T) { linkDir(unitsDirPath, "c", linkToDirPath) t.Setenv("QUADLET_UNIT_DIRS", unitsDirPath) - unitDirs = GetUnitDirs(true) + unitDirs = GetUnitDirs(true, true) assert.Equal(t, expectedDirs, unitDirs, "directory resolution should follow symlink") // remove the temporary directory at the end of the program defer os.RemoveAll(symLinkTestBaseDir) @@ -223,14 +223,14 @@ func TestUnitDirs(t *testing.T) { // Make sure QUADLET_UNIT_DIRS is not set t.Setenv("QUADLET_UNIT_DIRS", "") // Test Rootful - unitDirs := GetUnitDirs(false) + unitDirs := GetUnitDirs(false, true) assert.NotContains(t, unitDirs, userDir, "rootful should not contain rootless") assert.NotContains(t, unitDirs, userInternalDir, "rootful should not contain rootless") assert.NotContains(t, unitDirs, distroUserDir, "rootful should not contain distro rootless") assert.NotContains(t, unitDirs, distroInternalDir, "rootful should not contain distro rootless") // Test Rootless - unitDirs = GetUnitDirs(true) + unitDirs = GetUnitDirs(true, true) assert.NotContains(t, unitDirs, uidDir2, "rootless should not contain other users'") assert.Contains(t, unitDirs, userInternalDir, "rootless should contain sub-directories of users dir") assert.Contains(t, unitDirs, uidDir, "rootless should contain the directory for its UID") diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 90646deda5d..84abb2689af 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -185,31 +185,6 @@ t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_2.tar" 200 \ t GET "libpod/quadlets/$quadlet_2/file" 200 is "$output" "$quadlet_2_content" "quadlet-1 should be overwritten by quadlet-2" -# Scenario: install multiple quadlets at once in a single tar will fail -quadlet_3=quadlet-test-3-$(cat /proc/sys/kernel/random/uuid).container -quadlet_4=quadlet-test-4-$(cat /proc/sys/kernel/random/uuid).container - -quadlet_3_content=$(cat << EOF -[Container] -ContainerName=quadlet-3 -Image=quay.io/podman/hello -EOF -) - -quadlet_4_content=$(cat << EOF -[Container] -ContainerName=quadlet-4 -Image=quay.io/podman/hello -EOF -) - -echo "$quadlet_3_content" > "$TMPD/$quadlet_3" -echo "$quadlet_4_content" > "$TMPD/$quadlet_4" -tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_3_4.tar" "$quadlet_3" "$quadlet_4" &> /dev/null - -t POST "libpod/quadlets" "$TMPD/$quadlet_3_4.tar" 400 \ - .cause="only a single quadlet file is allowed per request" - # Scenario: install tar that contains one quadlet file and a non-quadlet file will succeed # then update the quadlet file, and the non-quadlet file, and verify the update is successful quadlet_5=quadlet-test-5-$(cat /proc/sys/kernel/random/uuid).container @@ -245,33 +220,33 @@ echo "$quadlet_5_content" > "$TMPD/$quadlet_5" echo "$containerfile_1_content" > "$TMPD/$containerfile_1" tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null -t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ +t POST "libpod/quadlets?application=bar" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ '.InstalledQuadlets|length=2' \ '.QuadletErrors|length=0' t GET "libpod/quadlets/$quadlet_5/file" 200 is "$output" "$quadlet_5_content" "quadlet-5 should be installed" -is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" +is "$(cat "$quadlet_install_dir/bar/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" echo "$quadlet_5_updated_content" > "$TMPD/$quadlet_5" echo "$containerfile_1_updated_content" > "$TMPD/$containerfile_1" tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null # update with no replace and check nothing changed -t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 400 +t POST "libpod/quadlets?application=bar" "$TMPD/$quadlet_5$containerfile_1.tar" 400 t GET "libpod/quadlets/$quadlet_5/file" 200 is "$output" "$quadlet_5_content" "quadlet-5 should be installed" -is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" +is "$(cat "$quadlet_install_dir/bar/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" # replace -t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ +t POST "libpod/quadlets?replace=true&application=bar" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ '.InstalledQuadlets|length=2' \ '.QuadletErrors|length=0' t GET "libpod/quadlets/$quadlet_5/file" 200 is "$output" "$quadlet_5_updated_content" "quadlet-5 should be updated" -is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_updated_content" "containerfile_1 should be installed" +is "$(cat "$quadlet_install_dir/bar/$containerfile_1")" "$containerfile_1_updated_content" "containerfile_1 should be installed" # Scenario: test a multipart call, then update without replace, and then replace quadlet_6=quadlet-test-6-$(cat /proc/sys/kernel/random/uuid).container @@ -328,6 +303,55 @@ t GET "libpod/quadlets/$quadlet_6/file" 200 is "$output" "$quadlet_6_updated_content" "quadlet-6 should be updated" is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_updated_content" "containerfile_2 should be updated" + +# Scenario: install and remove quadlets as application +quadlet_app_1=quadlet-app-test-1-$(cat /proc/sys/kernel/random/uuid).container +quadlet_app_2=quadlet-app-test-2-$(cat /proc/sys/kernel/random/uuid).container +quadlet_app_tar=quadlet-app-test-$(cat /proc/sys/kernel/random/uuid).tar +quadlet_app_1_content=$(cat << EOF +[Container] +ContainerName=quadlet-1 +Image=quay.io/podman/hello +EOF +) +quadlet_app_2_content=$(cat << EOF +[Container] +ContainerName=quadlet-2 +Image=quay.io/podman/hello +EOF +) + +echo "$quadlet_app_1_content" > "$TMPD/$quadlet_app_1" +echo "$quadlet_app_2_content" > "$TMPD/$quadlet_app_2" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_app_tar" "$quadlet_app_1" "$quadlet_app_2" &> /dev/null + +t POST "libpod/quadlets?application=hello-world" "$TMPD/$quadlet_app_tar" 200 \ + '.InstalledQuadlets|length=2' \ + '.QuadletErrors|length=0' +is "$(cat "$quadlet_install_dir/hello-world/$quadlet_app_1")" "$quadlet_app_1_content" "quadlet_app_1 should be installed in subdirectory" +is "$(cat "$quadlet_install_dir/hello-world/$quadlet_app_2")" "$quadlet_app_2_content" "quadlet_app_2 should be installed in subdirectory" + +t GET "libpod/quadlets/$quadlet_app_1/file" 200 +is "$output" "$quadlet_app_1_content" "quadlet_app_1 should be installed" + +t GET "libpod/quadlets/$quadlet_app_2/file" 200 +is "$output" "$quadlet_app_2_content" "quadlet_app_2 should be installed" + +filter_param='{"app":["hello-world"]}' +t GET "libpod/quadlets/json?filters=$filter_param" 200 \ + length=2 \ + .[0].Name="$quadlet_app_1" \ + .[0].App="hello-world" \ + .[1].Name="$quadlet_app_2" \ + .[1].App="hello-world" + +t DELETE "libpod/quadlets/hello-world?recursive=true" 200 \ + '.Removed|length=2' \ + '.Errors|length=0' + +t GET "libpod/quadlets/$quadlet_app_1/file" 404 +t GET "libpod/quadlets/$quadlet_app_2/file" 404 + # clean up podman quadlet rm "$quadlet_1" \ diff --git a/test/system/253-podman-quadlet.bats b/test/system/253-podman-quadlet.bats index 4d10ff442cf..506fe38ae30 100644 --- a/test/system/253-podman-quadlet.bats +++ b/test/system/253-podman-quadlet.bats @@ -180,8 +180,13 @@ Image=$IMAGE Environment=FOO1=foo1 Exec=sh -c "echo STARTED NGINX; trap 'exit' SIGTERM; while :; do sleep 0.1; done" EOF + + # Without --application should fail + run_podman 125 quadlet install $quadlet_dir + assert "$output" =~ "application name cannot be empty when installing from directory" "install from directory without --application must fail with application cannot be empty error message" + # Test quadlet install with directory - run_podman quadlet install $quadlet_dir + run_podman quadlet install --application=foo $quadlet_dir # Test quadlet list to verify all containers were installed run_podman quadlet list @@ -206,7 +211,7 @@ EOF assert "$output" =~ "Environment=FOO1=foo1" "print should contain environment for nginx container" # Test quadlet rm for all containers - run_podman quadlet rm ".$app_name.app" + run_podman quadlet rm "foo" --recursive # Verify all containers were removed run_podman quadlet list @@ -285,10 +290,10 @@ EOF local install_dir=$(get_quadlet_install_dir) # Test quadlet install with the directory containing the quadlet and test file - run_podman quadlet install $quadlet_dir $test_file + run_podman quadlet install --application=bar $quadlet_dir $test_file # Verify the content of the installed test.txt file - run -0 cat "$install_dir/test.txt" + run -0 cat "$install_dir/bar/test.txt" assert "$output" == "$mount_content" "installed test.txt should have correct content" # Test quadlet list to verify the container was installed @@ -562,7 +567,7 @@ EOF # Test quadlet rm --ignore behavior # Try to remove non-existent quadlets without --ignore (should fail) run_podman 125 quadlet rm non-existent.container - assert "$output" =~ "could not locate quadlet" "should fail to remove non-existent quadlet without --ignore" + assert "$output" =~ "some quadlets could not be removed" "should fail to remove non-existent quadlet without --ignore" # Try to remove non-existent quadlets with --ignore (should succeed) run_podman quadlet rm --ignore non-existent1.container non-existent2.container @@ -600,28 +605,6 @@ EOF run cat "$install_dir/long.container" assert "$output" == "$(<$PODMAN_TMPDIR/long.container)" "File was correctly truncated/replaced atomically" - # --- VERIFICATION 2: CHECK FOR DUPLICATES IN .APP FILE --- - - local app_file="$install_dir/.long.container.app" - - # Check if the file exists - if [ ! -f "$app_file" ]; then - # If .app is missing, check if .asset was created instead (debugging IsExtSupported) - if [ -f "$install_dir/.long.container.asset" ]; then - die "Failed: Created .asset file instead of .app file. IsExtSupported check failed?" - fi - die "Failed: .app file not found at $app_file" - fi - - # Check content of the .app file - run cat "$app_file" - # It should contain exactly one line: "long.container" - assert "$output" == "long.container" ".app file should contain the quadlet name" - - # Ensure no duplicates (line count should be 1) - run wc -l < "$app_file" - assert "$output" -eq 1 "Should only be listed once in tracking files" - # Cleanup: Remove the installed quadlet run_podman quadlet rm long.container } diff --git a/test/system/254-podman-quadlet-multi.bats b/test/system/254-podman-quadlet-multi.bats index e2b0fdb8df0..22ebe6e1181 100644 --- a/test/system/254-podman-quadlet-multi.bats +++ b/test/system/254-podman-quadlet-multi.bats @@ -98,7 +98,7 @@ WantedBy=multi-user.target EOF # Test quadlet install with multi-quadlet file - run_podman quadlet install $multi_quadlet_file + run_podman quadlet install --application=$app_name $multi_quadlet_file # Verify install output contains all three quadlet names assert "$output" =~ "${container_name}.container" "install output should contain ${container_name}.container" @@ -115,9 +115,9 @@ EOF assert "$output" =~ "${network_name}.network" "list should contain ${network_name}.network" # Verify the files exist on disk - [[ -f "$install_dir/${container_name}.container" ]] || die "${container_name}.container should exist on disk" - [[ -f "$install_dir/${volume_name}.volume" ]] || die "${volume_name}.volume should exist on disk" - [[ -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should exist on disk" + [[ -f "$install_dir/$app_name/${container_name}.container" ]] || die "${container_name}.container should exist on disk" + [[ -f "$install_dir/$app_name/${volume_name}.volume" ]] || die "${volume_name}.volume should exist on disk" + [[ -f "$install_dir/$app_name/${network_name}.network" ]] || die "${network_name}.network should exist on disk" # Test quadlet print for each installed quadlet and verify systemd sections are preserved run_podman quadlet print ${container_name}.container @@ -152,31 +152,14 @@ EOF assert "$output" =~ "\\[Install\\]" "print should show Install section" assert "$output" =~ "WantedBy=multi-user.target" "print should show WantedBy directive" - # Check that the .app file was created (all quadlets are part of the same application) - [[ -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should exist" - [[ ! -f "$install_dir/.${container_name}.container.asset" ]] || die "individual .asset files should not exist" - [[ ! -f "$install_dir/.${volume_name}.volume.asset" ]] || die "individual .asset files should not exist" - [[ ! -f "$install_dir/.${network_name}.network.asset" ]] || die "individual .asset files should not exist" - - # Verify the .app file contains all quadlet names - run cat "$install_dir/.${app_name}.app" - assert "$output" =~ "${container_name}.container" ".app file should contain ${container_name}.container" - assert "$output" =~ "${volume_name}.volume" ".app file should contain ${volume_name}.volume" - assert "$output" =~ "${network_name}.network" ".app file should contain ${network_name}.network" - # Test quadlet list to verify all quadlets show the same app name run_podman quadlet list local webserver_line=$(echo "$output" | grep "${container_name}.container") local appstorage_line=$(echo "$output" | grep "${volume_name}.volume") local appnetwork_line=$(echo "$output" | grep "${network_name}.network") - # All lines should contain the same app name (.${app_name}.app) - assert "$webserver_line" =~ "\\.${app_name}\\.app" "${container_name} should show .${app_name}.app as app" - assert "$appstorage_line" =~ "\\.${app_name}\\.app" "${volume_name} should show .${app_name}.app as app" - assert "$appnetwork_line" =~ "\\.${app_name}\\.app" "${network_name} should show .${app_name}.app as app" - - # Test quadlet rm for one of the quadlets - should remove entire application - run_podman quadlet rm ${container_name}.container + # Test quadlet rm for application + run_podman quadlet rm --recursive $app_name assert "$output" =~ "${container_name}.container" "remove output should contain ${container_name}.container" # Verify all quadlets were removed since they're part of the same app @@ -184,9 +167,6 @@ EOF assert "$output" !~ "${container_name}.container" "list should not contain removed ${container_name}.container" assert "$output" !~ "${volume_name}.volume" "list should not contain ${volume_name}.volume as app is removed" assert "$output" !~ "${network_name}.network" "list should not contain ${network_name}.network as app is removed" - - # The .app file should also be removed - [[ ! -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should be removed" } @test "quadlet verb - install multi-quadlet file with empty sections" { @@ -320,7 +300,7 @@ port=3000 EOF # Install the directory - run_podman quadlet install "$app_dir" + run_podman quadlet install "$app_dir" --application=$app_name # Verify all quadlets were installed (2 individual + 3 from .quadlets file = 5 total) assert "$output" =~ "${frontend_name}.container" "install output should contain ${frontend_name}.container" @@ -333,23 +313,12 @@ EOF assert "${#lines[@]}" -eq 6 "install output should contain exactly six lines" # Verify all files exist on disk - [[ -f "$install_dir/${frontend_name}.container" ]] || die "${frontend_name}.container should exist on disk" - [[ -f "$install_dir/${data_name}.volume" ]] || die "${data_name}.volume should exist on disk" - [[ -f "$install_dir/${api_name}.container" ]] || die "${api_name}.container should exist on disk" - [[ -f "$install_dir/${cache_name}.volume" ]] || die "${cache_name}.volume should exist on disk" - [[ -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should exist on disk" - [[ -f "$install_dir/app.conf" ]] || die "app.conf should exist on disk" - - # Check that the .app file was created (all files are part of one application) - [[ -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should exist" - - # Verify the .app file contains all quadlet names - run cat "$install_dir/.${app_name}.app" - assert "$output" =~ "${frontend_name}.container" ".app file should contain ${frontend_name}.container" - assert "$output" =~ "${data_name}.volume" ".app file should contain ${data_name}.volume" - assert "$output" =~ "${api_name}.container" ".app file should contain ${api_name}.container" - assert "$output" =~ "${cache_name}.volume" ".app file should contain ${cache_name}.volume" - assert "$output" =~ "${network_name}.network" ".app file should contain ${network_name}.network" + [[ -f "$install_dir/$app_name/${frontend_name}.container" ]] || die "${frontend_name}.container should exist on disk" + [[ -f "$install_dir/$app_name/${data_name}.volume" ]] || die "${data_name}.volume should exist on disk" + [[ -f "$install_dir/$app_name/${api_name}.container" ]] || die "${api_name}.container should exist on disk" + [[ -f "$install_dir/$app_name/${cache_name}.volume" ]] || die "${cache_name}.volume should exist on disk" + [[ -f "$install_dir/$app_name/${network_name}.network" ]] || die "${network_name}.network should exist on disk" + [[ -f "$install_dir/$app_name/app.conf" ]] || die "app.conf should exist on disk" # Test quadlet list to verify all quadlets show the same app name run_podman quadlet list @@ -359,28 +328,21 @@ EOF local cache_line=$(echo "$output" | grep "${cache_name}.volume") local network_line=$(echo "$output" | grep "${network_name}.network") - # All lines should contain the same app name (.${app_name}.app) - assert "$frontend_line" =~ "\\.${app_name}\\.app" "${frontend_name} should show .${app_name}.app as app" - assert "$data_line" =~ "\\.${app_name}\\.app" "${data_name} should show .${app_name}.app as app" - assert "$api_line" =~ "\\.${app_name}\\.app" "${api_name} should show .${app_name}.app as app" - assert "$cache_line" =~ "\\.${app_name}\\.app" "${cache_name} should show .${app_name}.app as app" - assert "$network_line" =~ "\\.${app_name}\\.app" "${network_name} should show .${app_name}.app as app" - # Verify content of individual quadlet files - run cat "$install_dir/${frontend_name}.container" + run cat "$install_dir/$app_name/${frontend_name}.container" assert "$output" =~ "\\[Container\\]" "frontend container file should contain [Container] section" assert "$output" =~ "ContainerName=frontend-app-" "frontend container file should contain correct name prefix" - run cat "$install_dir/${api_name}.container" + run cat "$install_dir/$app_name/${api_name}.container" assert "$output" =~ "\\[Container\\]" "api-server container file should contain [Container] section" assert "$output" =~ "ContainerName=api-server-" "api-server container file should contain correct name prefix" - run cat "$install_dir/${network_name}.network" + run cat "$install_dir/$app_name/${network_name}.network" assert "$output" =~ "\\[Network\\]" "network file should contain [Network] section" assert "$output" =~ "Subnet=192.168.1.0/24" "network file should contain correct subnet" # Test that removing one quadlet removes the entire application - run_podman quadlet rm ${frontend_name}.container + run_podman quadlet rm $app_name --recursive # All quadlets should be removed since they're part of the same app run_podman quadlet list @@ -390,14 +352,11 @@ EOF assert "$output" !~ "${cache_name}.volume" "${cache_name}.volume should also be removed as part of same app" assert "$output" !~ "${network_name}.network" "${network_name}.network should also be removed as part of same app" - # The .app file should also be removed - [[ ! -f "$install_dir/.${app_name}.app" ]] || die ".${app_name}.app file should be removed" - # All individual files should be removed - [[ ! -f "$install_dir/${frontend_name}.container" ]] || die "${frontend_name}.container should be removed" - [[ ! -f "$install_dir/${data_name}.volume" ]] || die "${data_name}.volume should be removed" - [[ ! -f "$install_dir/${api_name}.container" ]] || die "${api_name}.container should be removed" - [[ ! -f "$install_dir/${cache_name}.volume" ]] || die "${cache_name}.volume should be removed" - [[ ! -f "$install_dir/${network_name}.network" ]] || die "${network_name}.network should be removed" - [[ ! -f "$install_dir/app.conf" ]] || die "app.conf should be removed" + [[ ! -f "$install_dir/$app_name/${frontend_name}.container" ]] || die "${frontend_name}.container should be removed" + [[ ! -f "$install_dir/$app_name/${data_name}.volume" ]] || die "${data_name}.volume should be removed" + [[ ! -f "$install_dir/$app_name/${api_name}.container" ]] || die "${api_name}.container should be removed" + [[ ! -f "$install_dir/$app_name/${cache_name}.volume" ]] || die "${cache_name}.volume should be removed" + [[ ! -f "$install_dir/$app_name/${network_name}.network" ]] || die "${network_name}.network should be removed" + [[ ! -f "$install_dir/$app_name/app.conf" ]] || die "app.conf should be removed" }