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..9aacdb58290 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 app`, } 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..7c70548dcfd 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: An application is a collection of files, quadlet and non-quadlets, that +need to live together. As such, removing a quadlet that is part of an +application will remove the entire application. When a quadlet is installed +from a directory, all files installed from that directory—including both +quadlet and non-quadlet files—are considered part of a single application. -Note: In case user wants to install Quadlet application then first path should be the path to application directory. +Note: When multiple quadlets are installed from a single `.quadlets` file, +they are all considered part of the same application only when an application +name is provided using the `--application` option. ## 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..eedac9befb2 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 @@ -23,12 +23,16 @@ Remove all Quadlets for the current user. #### **--force**, **-f** -Remove running quadlets. +Remove running Quadlets. #### **--ignore**, **-i** 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 3afa0ed1717..51706aab11b 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,42 +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 && slices.Contains(strings.Split(string(content), "\n"), 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 @@ -552,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() @@ -677,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) + return nil, fmt.Errorf("cannot use filters: %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) + 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) } @@ -796,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 { @@ -831,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. @@ -882,141 +554,126 @@ 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) + // Add all application if recursive is true + if options.Recursive { + for application := range applications { + quadlets = append(quadlets, application) } - 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 + } + } + + // Create a map filename -> quadlet + files := make(map[string]*entities.ListQuadlet, len(units)) + for _, unit := range units { + files[unit.Name] = unit + } + + // Iterate over the list of quadlets to remove + for _, quadlet := range quadlets { + var ( + applicationName string + isUnit bool + ) + + // Check if the parameter passed is a valid unit name + // Otherwise it must be the name of an application + isUnit = systemdquadlet.IsExtSupported(quadlet) + + if isUnit { + unit := files[quadlet] + if unit == nil { + if options.Ignore { + report.Removed = append(report.Removed, quadlet) + } else { + report.Errors[quadlet] = fmt.Errorf("no such quadlet") } - if !slices.Contains(quadlets, entry) { - quadlets = append(quadlets, entry) + continue + } + + applicationName = unit.App + + // If the unit isn't part of an application remove it + if applicationName == "" { + err := removeQuadlet(ctx, conn, unit, options.Force) + if err != nil { + report.Errors[quadlet] = err + } else { + report.Removed = append(report.Removed, unit.Name) } + continue } } - allQuadletPaths = append(allQuadletPaths, quadletPath) - - serviceName, err := getQuadletServiceName(quadletPath) - if err != nil { - report.Errors[quadlet] = err - continue + if applicationName == "" { + applicationName = quadlet } - allServiceNames = append(allServiceNames, serviceName) - serviceNameToQuadletName[serviceName] = quadlet - } + // delete an application + if len(applications[applicationName]) == 0 { + return nil, fmt.Errorf("no such application %q", applicationName) + } - 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) + if !options.Recursive { + return nil, fmt.Errorf("refusing to remove application %q: recursive option is not set", applicationName) } - for _, unitStatus := range statuses { - quadletName := serviceNameToQuadletName[unitStatus.Name] - if unitStatus.LoadState != "loaded" { - // Nothing to do here if it doesn't exist in systemd - 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 - } + removeFailed := false + for _, unit := range applications[applicationName] { + err := removeQuadlet(ctx, conn, unit, options.Force) + if err != nil { + removeFailed = true + // Use applicationName rather than unit.Name as + // the `report.Errors` key because function + // `RemoveQuadlet` uses it to look for errors + // of a specific application removal. + report.Errors[applicationName] = err + } else { + report.Removed = append(report.Removed, unit.Name) } } - } - // 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 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 + // clean up application folder when no error + if !removeFailed { + appPath, err := getApplicationPath(applicationName) + if err != nil { + report.Errors[applicationName] = err + } else { + err = os.RemoveAll(appPath) + if err != nil { + report.Errors[applicationName] = err } } - for _, entry := range removeList { - os.Remove(filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), entry)) - } - 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..aaa5d92c9b3 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,9 +303,58 @@ 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" \ +podman quadlet rm --recursive "$quadlet_1" \ "$quadlet_5" \ "$quadlet_6" diff --git a/test/system/253-podman-quadlet.bats b/test/system/253-podman-quadlet.bats index 4d10ff442cf..3fc22237cb2 100644 --- a/test/system/253-podman-quadlet.bats +++ b/test/system/253-podman-quadlet.bats @@ -18,6 +18,8 @@ function setup() { } function teardown() { + # remove any remaining quadlets from tests + run_podman quadlet rm --all --recursive -f systemctl daemon-reload basic_teardown } @@ -180,8 +182,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 @@ -205,8 +212,99 @@ EOF run_podman quadlet print nginx.container assert "$output" =~ "Environment=FOO1=foo1" "print should contain environment for nginx container" + # Test quadlet rm using one quadlet file name without recursive (should fail) + run_podman 125 quadlet rm "foo" + assert "$output" =~ "recursive option is not set" "rm application without --recursive must fail" + # Test quadlet rm for all containers - run_podman quadlet rm ".$app_name.app" + run_podman quadlet rm "foo" --recursive + + # Determine the install directory path based on rootless/root + local install_dir=$(get_quadlet_install_dir) + + # Verify the application folder should not exists in $install_dir + if [[ -f "$install_dir/foo" ]]; then + die "application folder should not exist in install directory $install_dir after removal" + fi + + + # Verify all containers were removed + run_podman quadlet list + assert "${#lines[@]}" -ge 1 "list should have at least a header line" + assert "${lines[0]}" =~ "NAME" "header should include NAME" + assert "${lines[0]}" =~ "POD" "header should include POD column" + assert "$output" !~ "alpine1.container" "list should not contain removed quadlets" +} + +@test "quadlet verb - install multiple files from directory and remove by file" { + # Create a directory for multiple quadlet files + local app_name="test-app-$(safe_name)" + local quadlet_dir="$PODMAN_TMPDIR/$app_name" + mkdir -p $quadlet_dir + + # Create multiple quadlet files with different configurations + cat > $quadlet_dir/alpine1.container < $quadlet_dir/alpine2.container < $quadlet_dir/nginx.container <