diff --git a/cmd/podman/common/completion.go b/cmd/podman/common/completion.go
index 2a718c8b77..de2ff0aa0f 100644
--- a/cmd/podman/common/completion.go
+++ b/cmd/podman/common/completion.go
@@ -173,6 +173,28 @@ func getPods(cmd *cobra.Command, toComplete string, cType completeType, statuses
return suggestions, cobra.ShellCompDirectiveNoFileComp
}
+func getQuadlets(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) {
+ suggestions := []string{}
+ lsOpts := entities.QuadletListOptions{}
+ engine, err := setupContainerEngine(cmd)
+ if err != nil {
+ cobra.CompErrorln(err.Error())
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ quadlets, err := engine.QuadletList(registry.Context(), lsOpts)
+ if err != nil {
+ cobra.CompErrorln(err.Error())
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ for _, q := range quadlets {
+ if strings.HasPrefix(q.Name, toComplete) {
+ suggestions = append(suggestions, q.Name)
+ }
+ }
+ return suggestions, cobra.ShellCompDirectiveNoFileComp
+}
+
func getVolumes(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) {
suggestions := []string{}
lsOpts := entities.VolumeListOptions{}
@@ -730,6 +752,14 @@ func AutocompleteImages(cmd *cobra.Command, args []string, toComplete string) ([
return getImages(cmd, toComplete)
}
+// AutocompleteQuadlets - Autocomplete quadlets.
+func AutocompleteQuadlets(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ if !validCurrentCmdLine(cmd, args, toComplete) {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+ return getQuadlets(cmd, toComplete)
+}
+
// AutocompleteManifestListAndMember - Autocomplete names of manifest lists and digests of items in them.
func AutocompleteManifestListAndMember(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if !validCurrentCmdLine(cmd, args, toComplete) {
@@ -827,6 +857,11 @@ func AutocompleteDefaultOneArg(cmd *cobra.Command, args []string, toComplete str
return nil, cobra.ShellCompDirectiveNoFileComp
}
+// AutocompleteDefaultManyArg - Autocomplete for many args.
+func AutocompleteDefaultManyArg(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ return nil, cobra.ShellCompDirectiveDefault
+}
+
// AutocompleteCommitCommand - Autocomplete podman commit command args.
func AutocompleteCommitCommand(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if !validCurrentCmdLine(cmd, args, toComplete) {
@@ -1775,6 +1810,14 @@ func AutocompletePsFilters(cmd *cobra.Command, args []string, toComplete string)
return completeKeyValues(toComplete, kv)
}
+// AutocompleteQuadletFilters - Autocomplete quadlet filter options.
+func AutocompleteQuadletFilters(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ kv := keyValueCompletion{
+ "name=": func(s string) ([]string, cobra.ShellCompDirective) { return getQuadlets(cmd, s) },
+ }
+ return completeKeyValues(toComplete, kv)
+}
+
// AutocompletePodPsFilters - Autocomplete pod ps filter options.
func AutocompletePodPsFilters(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
kv := keyValueCompletion{
diff --git a/cmd/podman/main.go b/cmd/podman/main.go
index 309a421529..80ce600bf9 100644
--- a/cmd/podman/main.go
+++ b/cmd/podman/main.go
@@ -19,6 +19,7 @@ import (
_ "github.com/containers/podman/v5/cmd/podman/manifest"
_ "github.com/containers/podman/v5/cmd/podman/networks"
_ "github.com/containers/podman/v5/cmd/podman/pods"
+ _ "github.com/containers/podman/v5/cmd/podman/quadlet"
"github.com/containers/podman/v5/cmd/podman/registry"
_ "github.com/containers/podman/v5/cmd/podman/secrets"
_ "github.com/containers/podman/v5/cmd/podman/system"
@@ -26,6 +27,7 @@ import (
"github.com/containers/podman/v5/cmd/podman/validate"
_ "github.com/containers/podman/v5/cmd/podman/volumes"
"github.com/containers/podman/v5/pkg/domain/entities"
+ "github.com/containers/podman/v5/pkg/logiface"
"github.com/containers/podman/v5/pkg/rootless"
"github.com/containers/podman/v5/pkg/terminal"
"github.com/containers/storage/pkg/reexec"
@@ -34,12 +36,22 @@ import (
"golang.org/x/term"
)
+type logrusLogger struct{}
+
+func (l logrusLogger) Errorf(format string, args ...interface{}) {
+ logrus.Errorf(format, args...)
+}
+func (l logrusLogger) Debugf(format string, args ...interface{}) {
+ logrus.Debugf(format, args...)
+}
+
func main() {
if reexec.Init() {
// We were invoked with a different argv[0] indicating that we
// had a specific job to do as a subprocess, and it's done.
return
}
+ logiface.SetLogger(logrusLogger{})
if filepath.Base(os.Args[0]) == registry.PodmanSh ||
(len(os.Args[0]) > 0 && filepath.Base(os.Args[0][1:]) == registry.PodmanSh) {
diff --git a/cmd/podman/quadlet/install.go b/cmd/podman/quadlet/install.go
new file mode 100644
index 0000000000..c9c9d8aba5
--- /dev/null
+++ b/cmd/podman/quadlet/install.go
@@ -0,0 +1,68 @@
+package quadlet
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/containers/podman/v5/cmd/podman/common"
+ "github.com/containers/podman/v5/cmd/podman/registry"
+ "github.com/containers/podman/v5/cmd/podman/utils"
+ "github.com/containers/podman/v5/pkg/domain/entities"
+ "github.com/spf13/cobra"
+)
+
+var (
+ quadletInstallDescription = `Install a quadlet file or quadlet application. Quadlets may be specified as local files, Web URLs, and OCI artifacts.`
+
+ quadletInstallCmd = &cobra.Command{
+ Use: "install [options] QUADLET-PATH-OR-URL [FILES-PATH-OR-URL...]",
+ Short: "Install a quadlet file or quadlet application",
+ Long: quadletInstallDescription,
+ RunE: install,
+ Args: func(_ *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return fmt.Errorf("must provide at least one argument")
+ }
+ return nil
+ },
+ ValidArgsFunction: common.AutocompleteDefaultManyArg,
+ Example: `podman quadlet install /path/to/myquadlet.container
+podman quadlet install https://github.com/containers/podman/blob/main/test/e2e/quadlet/basic.container
+podman quadlet install oci-artifact://my-artifact:latest`,
+ }
+
+ installOptions entities.QuadletInstallOptions
+)
+
+func installFlags(cmd *cobra.Command) {
+ flags := cmd.Flags()
+ flags.BoolVar(&installOptions.ReloadSystemd, "reload-systemd", true, "Reload systemd after installing Quadlets")
+}
+
+func init() {
+ registry.Commands = append(registry.Commands, registry.CliCommand{
+ Command: quadletInstallCmd,
+ Parent: quadletCmd,
+ })
+ installFlags(quadletInstallCmd)
+}
+
+func install(cmd *cobra.Command, args []string) error {
+ var errs utils.OutputErrors
+ installReport, err := registry.ContainerEngine().QuadletInstall(registry.Context(), args, installOptions)
+ if err != nil {
+ return err
+ }
+ for pathOrURL, err := range installReport.QuadletErrors {
+ errs = append(errs, fmt.Errorf("quadlet %q failed to install: %v", pathOrURL, err))
+ }
+ for _, s := range installReport.InstalledQuadlets {
+ fmt.Println(s)
+ }
+
+ if len(installReport.QuadletErrors) > 0 {
+ errs = append(errs, errors.New("errors occurred installing some Quadlets"))
+ }
+
+ return errs.PrintErrors()
+}
diff --git a/cmd/podman/quadlet/list.go b/cmd/podman/quadlet/list.go
new file mode 100644
index 0000000000..a3a95f9023
--- /dev/null
+++ b/cmd/podman/quadlet/list.go
@@ -0,0 +1,102 @@
+package quadlet
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/containers/common/pkg/completion"
+ "github.com/containers/common/pkg/report"
+ "github.com/containers/podman/v5/cmd/podman/common"
+ "github.com/containers/podman/v5/cmd/podman/registry"
+ "github.com/containers/podman/v5/cmd/podman/validate"
+ "github.com/containers/podman/v5/pkg/domain/entities"
+ "github.com/spf13/cobra"
+)
+
+var (
+ quadletListDescription = `List all Quadlets configured for the current user.`
+
+ quadletListCmd = &cobra.Command{
+ Use: "list [options]",
+ Short: "List Quadlets",
+ Long: quadletListDescription,
+ RunE: list,
+ Args: validate.NoArgs,
+ ValidArgsFunction: completion.AutocompleteNone,
+ Example: `podman quadlet list
+podman quadlet list --format '{{ .Unit }}'
+podman quadlet list --filter 'name=test*'`,
+ }
+
+ listOptions entities.QuadletListOptions
+ format string
+)
+
+func listFlags(cmd *cobra.Command) {
+ formatFlagName := "format"
+ filterFlagName := "filter"
+ flags := cmd.Flags()
+
+ flags.StringArrayVarP(&listOptions.Filters, filterFlagName, "f", []string{}, "Filter output based on conditions given")
+ flags.StringVar(&format, formatFlagName, "{{range .}}{{.Name}}\t{{.UnitName}}\t{{.Path}}\t{{.Status}}\t{{.App}}\n{{end -}}", "Pretty-print output to JSON or using a Go template")
+ _ = quadletListCmd.RegisterFlagCompletionFunc(formatFlagName, common.AutocompleteFormat(&entities.ListQuadlet{}))
+ _ = quadletListCmd.RegisterFlagCompletionFunc(filterFlagName, common.AutocompleteQuadletFilters)
+}
+
+func init() {
+ registry.Commands = append(registry.Commands, registry.CliCommand{
+ Command: quadletListCmd,
+ Parent: quadletCmd,
+ })
+ listFlags(quadletListCmd)
+}
+
+func list(cmd *cobra.Command, args []string) error {
+ quadlets, err := registry.ContainerEngine().QuadletList(registry.Context(), listOptions)
+ if err != nil {
+ return err
+ }
+
+ if report.IsJSON(format) {
+ return outputJSON(quadlets)
+ }
+ return outputTemplate(cmd, quadlets)
+}
+
+func outputTemplate(cmd *cobra.Command, responses []*entities.ListQuadlet) error {
+ headers := report.Headers(entities.ListQuadlet{}, map[string]string{
+ "Name": "NAME",
+ "UnitName": "UNIT NAME",
+ "Path": "PATH ON DISK",
+ "Status": "STATUS",
+ "App": "APPLICATION",
+ })
+
+ rpt := report.New(os.Stdout, cmd.Name())
+ defer rpt.Flush()
+
+ var err error
+ origin := report.OriginPodman
+ if cmd.Flag("format").Changed {
+ origin = report.OriginUser
+ }
+ rpt, err = rpt.Parse(origin, format)
+ if err != nil {
+ return err
+ }
+
+ if err := rpt.Execute(headers); err != nil {
+ return fmt.Errorf("writing column headers: %w", err)
+ }
+
+ return rpt.Execute(responses)
+}
+
+func outputJSON(vols []*entities.ListQuadlet) error {
+ b, err := json.MarshalIndent(vols, "", " ")
+ if err != nil {
+ return err
+ }
+ fmt.Println(string(b))
+ return nil
+}
diff --git a/cmd/podman/quadlet/print.go b/cmd/podman/quadlet/print.go
new file mode 100644
index 0000000000..70a4bf087f
--- /dev/null
+++ b/cmd/podman/quadlet/print.go
@@ -0,0 +1,43 @@
+package quadlet
+
+import (
+ "fmt"
+
+ "github.com/containers/podman/v5/cmd/podman/common"
+ "github.com/containers/podman/v5/cmd/podman/registry"
+ "github.com/spf13/cobra"
+)
+
+var (
+ quadletPrintDescription = `Print the contents of a Quadlet, displaying the file including all comments`
+
+ quadletPrintCmd = &cobra.Command{
+ Use: "print QUADLET",
+ Short: "Display the contents of a quadlet",
+ Long: quadletPrintDescription,
+ RunE: print,
+ ValidArgsFunction: common.AutocompleteQuadlets,
+ Args: cobra.ExactArgs(1),
+ Example: `podman quadlet print myquadlet.container
+podman quadlet print mypod.pod
+podman quadlet print myimage.build`,
+ }
+)
+
+func init() {
+ registry.Commands = append(registry.Commands, registry.CliCommand{
+ Command: quadletPrintCmd,
+ Parent: quadletCmd,
+ })
+}
+
+func print(cmd *cobra.Command, args []string) error {
+ quadletContents, err := registry.ContainerEngine().QuadletPrint(registry.Context(), args[0])
+ if err != nil {
+ return err
+ }
+
+ fmt.Print(quadletContents)
+
+ return nil
+}
diff --git a/cmd/podman/quadlet/quadlet.go b/cmd/podman/quadlet/quadlet.go
new file mode 100644
index 0000000000..d5255e0ab7
--- /dev/null
+++ b/cmd/podman/quadlet/quadlet.go
@@ -0,0 +1,26 @@
+package quadlet
+
+import (
+ "github.com/containers/podman/v5/cmd/podman/registry"
+ "github.com/containers/podman/v5/cmd/podman/validate"
+ "github.com/spf13/cobra"
+)
+
+var (
+ // Pull in configured json library
+ json = registry.JSONLibrary()
+
+ // Command: podman _quadlet_
+ quadletCmd = &cobra.Command{
+ Use: "quadlet",
+ Short: "Allows users to manage Quadlets",
+ Long: "Allows users to manage Quadlets",
+ RunE: validate.SubCommandExists,
+ }
+)
+
+func init() {
+ registry.Commands = append(registry.Commands, registry.CliCommand{
+ Command: quadletCmd,
+ })
+}
diff --git a/cmd/podman/quadlet/remove.go b/cmd/podman/quadlet/remove.go
new file mode 100644
index 0000000000..228cd5164f
--- /dev/null
+++ b/cmd/podman/quadlet/remove.go
@@ -0,0 +1,67 @@
+package quadlet
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/containers/podman/v5/cmd/podman/common"
+ "github.com/containers/podman/v5/cmd/podman/registry"
+ "github.com/containers/podman/v5/cmd/podman/utils"
+ "github.com/containers/podman/v5/pkg/domain/entities"
+ "github.com/spf13/cobra"
+)
+
+var (
+ quadletRmDescription = `Remove one or more installed Quadlets from the current user`
+
+ quadletRmCmd = &cobra.Command{
+ Use: "rm [options] QUADLET [QUADLET...]",
+ 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`,
+ }
+
+ removeOptions entities.QuadletRemoveOptions
+)
+
+func rmFlags(cmd *cobra.Command) {
+ flags := cmd.Flags()
+
+ flags.BoolVarP(&removeOptions.Force, "force", "f", false, "Remove running quadlets")
+ 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", false, "Reload systemd after removal")
+}
+
+func init() {
+ registry.Commands = append(registry.Commands, registry.CliCommand{
+ Command: quadletRmCmd,
+ Parent: quadletCmd,
+ })
+ rmFlags(quadletRmCmd)
+}
+
+func rm(cmd *cobra.Command, args []string) error {
+ if len(args) < 1 && !removeOptions.All {
+ return errors.New("at least one quadlet file must be selected")
+ }
+ var errs utils.OutputErrors
+ removeReport, err := registry.ContainerEngine().QuadletRemove(registry.Context(), args, removeOptions)
+ // We can get a report back even if err != nil if systemd reload failed
+ if removeReport != nil {
+ for _, rq := range removeReport.Removed {
+ fmt.Println(rq)
+ }
+ for quadlet, quadletErr := range removeReport.Errors {
+ errs = append(errs, fmt.Errorf("unable to remove Quadlet %s: %v", quadlet, quadletErr))
+ }
+ if err == nil && len(removeReport.Errors) > 0 {
+ errs = append(errs, errors.New("some quadlets could not be removed"))
+ }
+ }
+ return errs.PrintErrors()
+}
diff --git a/cmd/quadlet/main.go b/cmd/quadlet/main.go
index 4f9af0075d..529f55f4e7 100644
--- a/cmd/quadlet/main.go
+++ b/cmd/quadlet/main.go
@@ -4,12 +4,9 @@ import (
"errors"
"flag"
"fmt"
- "io/fs"
"os"
- "os/user"
"path"
"path/filepath"
- "regexp"
"sort"
"strings"
"unicode"
@@ -92,219 +89,6 @@ func Debugf(format string, a ...interface{}) {
}
}
-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{}
-}
-
-func newSearchPaths() *searchPaths {
- return &searchPaths{
- sorted: make([]string, 0),
- visitedDirs: make(map[string]struct{}, 0),
- }
-}
-
-func (s *searchPaths) Add(path string) {
- s.sorted = append(s.sorted, path)
- s.visitedDirs[path] = struct{}{}
-}
-
-func (s *searchPaths) Visited(path string) bool {
- _, visited := s.visitedDirs[path]
- return visited
-}
-
-// This returns the directories where we read quadlet .container and .volumes from
-// 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, and $XDG_CONFIG_HOME/containers/systemd
-func getUnitDirs(rootless bool) []string {
- paths := newSearchPaths()
-
- // Allow overriding source dir, this is mainly for the CI tests
- if getDirsFromEnv(paths) {
- return paths.sorted
- }
-
- resolvedUnitDirAdminUser := resolveUnitDirAdminUser()
- userLevelFilter := getUserLevelFilter(resolvedUnitDirAdminUser)
-
- if rootless {
- systemUserDirLevel := len(strings.Split(resolvedUnitDirAdminUser, string(os.PathSeparator)))
- nonNumericFilter := getNonNumericFilter(resolvedUnitDirAdminUser, systemUserDirLevel)
- getRootlessDirs(paths, nonNumericFilter, userLevelFilter)
- } else {
- getRootDirs(paths, userLevelFilter)
- }
- return paths.sorted
-}
-
-func getDirsFromEnv(paths *searchPaths) bool {
- unitDirsEnv := os.Getenv("QUADLET_UNIT_DIRS")
- if len(unitDirsEnv) == 0 {
- return false
- }
-
- for _, eachUnitDir := range strings.Split(unitDirsEnv, ":") {
- if !filepath.IsAbs(eachUnitDir) {
- Logf("%s not a valid file path", eachUnitDir)
- break
- }
- appendSubPaths(paths, eachUnitDir, false, nil)
- }
- return true
-}
-
-func getRootlessDirs(paths *searchPaths, nonNumericFilter, userLevelFilter func(string, bool) bool) {
- runtimeDir, found := os.LookupEnv("XDG_RUNTIME_DIR")
- if found {
- appendSubPaths(paths, path.Join(runtimeDir, "containers/systemd"), false, nil)
- }
-
- configDir, err := os.UserConfigDir()
- if err != nil {
- fmt.Fprintf(os.Stderr, "Warning: %v", err)
- return
- }
- appendSubPaths(paths, path.Join(configDir, "containers/systemd"), false, nil)
-
- u, err := user.Current()
- if err == nil {
- appendSubPaths(paths, filepath.Join(quadlet.UnitDirAdmin, "users"), true, nonNumericFilter)
- appendSubPaths(paths, filepath.Join(quadlet.UnitDirAdmin, "users", u.Uid), true, userLevelFilter)
- } else {
- fmt.Fprintf(os.Stderr, "Warning: %v", err)
- // Add the base directory even if the UID was not found
- paths.Add(filepath.Join(quadlet.UnitDirAdmin, "users"))
- }
-}
-
-func getRootDirs(paths *searchPaths, userLevelFilter func(string, bool) bool) {
- appendSubPaths(paths, quadlet.UnitDirTemp, false, userLevelFilter)
- appendSubPaths(paths, quadlet.UnitDirAdmin, false, userLevelFilter)
- appendSubPaths(paths, quadlet.UnitDirDistro, false, nil)
-}
-
-func resolveUnitDirAdminUser() string {
- unitDirAdminUser := filepath.Join(quadlet.UnitDirAdmin, "users")
- var err error
- var resolvedUnitDirAdminUser string
- if resolvedUnitDirAdminUser, err = filepath.EvalSymlinks(unitDirAdminUser); err != nil {
- if !errors.Is(err, fs.ErrNotExist) {
- Debugf("Error occurred resolving path %q: %s", unitDirAdminUser, err)
- }
- resolvedUnitDirAdminUser = unitDirAdminUser
- }
- return resolvedUnitDirAdminUser
-}
-
-func appendSubPaths(paths *searchPaths, path string, isUserFlag bool, filterPtr func(string, bool) bool) {
- resolvedPath, err := filepath.EvalSymlinks(path)
- if err != nil {
- if !errors.Is(err, fs.ErrNotExist) {
- Debugf("Error occurred resolving path %q: %s", path, err)
- }
- // Despite the failure add the path to the list for logging purposes
- // This is the equivalent of adding the path when info==nil below
- paths.Add(path)
- return
- }
-
- if skipPath(paths, resolvedPath, isUserFlag, filterPtr) {
- return
- }
-
- // Add the current directory
- paths.Add(resolvedPath)
-
- // Read the contents of the directory
- entries, err := os.ReadDir(resolvedPath)
- if err != nil {
- if !errors.Is(err, os.ErrNotExist) {
- Debugf("Error occurred walking sub directories %q: %s", path, err)
- }
- return
- }
-
- // Recursively run through the contents of the directory
- for _, entry := range entries {
- fullPath := filepath.Join(resolvedPath, entry.Name())
- appendSubPaths(paths, fullPath, isUserFlag, filterPtr)
- }
-}
-
-func skipPath(paths *searchPaths, path string, isUserFlag bool, filterPtr func(string, bool) bool) bool {
- // If the path is already in the map no need to read it again
- if paths.Visited(path) {
- return true
- }
-
- // Don't traverse drop-in directories
- if strings.HasSuffix(path, ".d") {
- return true
- }
-
- // Check if the directory should be filtered out
- if filterPtr != nil && !filterPtr(path, isUserFlag) {
- return true
- }
-
- stat, err := os.Stat(path)
- if err != nil {
- if !errors.Is(err, fs.ErrNotExist) {
- Debugf("Error occurred resolving path %q: %s", path, err)
- }
- return true
- }
-
- // Not a directory nothing to add
- return !stat.IsDir()
-}
-
-func getNonNumericFilter(resolvedUnitDirAdminUser string, systemUserDirLevel int) func(string, bool) bool {
- return func(path string, isUserFlag bool) bool {
- // when running in rootless, recursive walk directories that are non numeric
- // ignore sub dirs under the `users` directory which correspond to a user id
- if strings.HasPrefix(path, resolvedUnitDirAdminUser) {
- listDirUserPathLevels := strings.Split(path, string(os.PathSeparator))
- // Make sure to add the base directory
- if len(listDirUserPathLevels) == systemUserDirLevel {
- return true
- }
- if len(listDirUserPathLevels) > systemUserDirLevel {
- if !(regexp.MustCompile(`^[0-9]*$`).MatchString(listDirUserPathLevels[systemUserDirLevel])) {
- return true
- }
- }
- } else {
- return true
- }
- return false
- }
-}
-
-func getUserLevelFilter(resolvedUnitDirAdminUser string) func(string, bool) bool {
- return func(_path string, isUserFlag bool) bool {
- // if quadlet generator is run rootless, do not recurse other user sub dirs
- // if quadlet generator is run as root, ignore users sub dirs
- if strings.HasPrefix(_path, resolvedUnitDirAdminUser) {
- if isUserFlag {
- return true
- }
- } else {
- return true
- }
- return false
- }
-}
-
-func isExtSupported(filename string) bool {
- ext := filepath.Ext(filename)
- _, ok := quadlet.SupportedExtensions[ext]
- return ok
-}
-
var seen = make(map[string]struct{})
func loadUnitsFromDir(sourcePath string) ([]*parser.UnitFile, error) {
@@ -321,7 +105,7 @@ func loadUnitsFromDir(sourcePath string) ([]*parser.UnitFile, error) {
for _, file := range files {
name := file.Name()
- if _, ok := seen[name]; !ok && isExtSupported(name) {
+ if _, ok := seen[name]; !ok && quadlet.IsExtSupported(name) {
path := path.Join(sourcePath, name)
Debugf("Loading source unit file %s", path)
@@ -580,33 +364,30 @@ func generateUnitsInfoMap(units []*parser.UnitFile) map[string]*quadlet.UnitInfo
var serviceName string
var containers []string
var resourceName string
+ var err error
+
+ serviceName, err = quadlet.GetUnitServiceName(unit)
+ if err != nil {
+ Logf("Error obtaining service name: %v", err)
+ }
switch {
case strings.HasSuffix(unit.Filename, ".container"):
- serviceName = quadlet.GetContainerServiceName(unit)
// Prefill resouceNames for .container files. This solves network reusing.
resourceName = quadlet.GetContainerResourceName(unit)
- case strings.HasSuffix(unit.Filename, ".volume"):
- serviceName = quadlet.GetVolumeServiceName(unit)
- case strings.HasSuffix(unit.Filename, ".kube"):
- serviceName = quadlet.GetKubeServiceName(unit)
- case strings.HasSuffix(unit.Filename, ".network"):
- serviceName = quadlet.GetNetworkServiceName(unit)
- case strings.HasSuffix(unit.Filename, ".image"):
- serviceName = quadlet.GetImageServiceName(unit)
case strings.HasSuffix(unit.Filename, ".build"):
- serviceName = quadlet.GetBuildServiceName(unit)
// Prefill resouceNames for .build files. This is significantly less complex than
// pre-computing all resourceNames for all Quadlet types (which is rather complex for a few
// types), but still breaks the dependency cycle between .volume and .build ([Volume] can
// have Image=some.build, and [Build] can have Volume=some.volume:/some-volume)
resourceName = quadlet.GetBuiltImageName(unit)
case strings.HasSuffix(unit.Filename, ".pod"):
- serviceName = quadlet.GetPodServiceName(unit)
containers = make([]string, 0)
// Prefill resouceNames for .pod files.
// This is requires for referencing the pod from .container files
resourceName = quadlet.GetPodResourceName(unit)
+ case strings.HasSuffix(unit.Filename, ".volume"), strings.HasSuffix(unit.Filename, ".kube"), strings.HasSuffix(unit.Filename, ".network"), strings.HasSuffix(unit.Filename, ".image"):
+ // Do nothing for these case.
default:
Logf("Unsupported file type %q", unit.Filename)
continue
@@ -669,7 +450,7 @@ func process() bool {
Debugf("Starting quadlet-generator, output to: %s", outputPath)
}
- sourcePathsMap := getUnitDirs(isUserFlag)
+ sourcePathsMap := quadlet.GetUnitDirs(isUserFlag)
var units []*parser.UnitFile
for _, d := range sourcePathsMap {
diff --git a/cmd/quadlet/main_test.go b/cmd/quadlet/main_test.go
index 52bfcd1cef..a9f137e861 100644
--- a/cmd/quadlet/main_test.go
+++ b/cmd/quadlet/main_test.go
@@ -3,18 +3,8 @@
package main
import (
- "fmt"
- "os"
- "os/exec"
- "os/user"
- "path"
- "path/filepath"
- "strconv"
- "strings"
- "syscall"
"testing"
- "github.com/containers/podman/v5/pkg/systemd/quadlet"
"github.com/stretchr/testify/assert"
)
@@ -52,208 +42,3 @@ func TestIsUnambiguousName(t *testing.T) {
assert.Equal(t, res, test.res, "%q", test.input)
}
}
-
-func TestUnitDirs(t *testing.T) {
- u, err := user.Current()
- assert.NoError(t, err)
- uidInt, err := strconv.Atoi(u.Uid)
- assert.NoError(t, err)
-
- if os.Getenv("_UNSHARED") != "true" {
- unitDirs := getUnitDirs(false)
-
- resolvedUnitDirAdminUser := resolveUnitDirAdminUser()
- userLevelFilter := getUserLevelFilter(resolvedUnitDirAdminUser)
- rootfulPaths := newSearchPaths()
- appendSubPaths(rootfulPaths, quadlet.UnitDirTemp, false, userLevelFilter)
- appendSubPaths(rootfulPaths, quadlet.UnitDirAdmin, false, userLevelFilter)
- appendSubPaths(rootfulPaths, quadlet.UnitDirDistro, false, userLevelFilter)
- assert.Equal(t, rootfulPaths.sorted, unitDirs, "rootful unit dirs should match")
-
- configDir, err := os.UserConfigDir()
- assert.NoError(t, err)
-
- rootlessPaths := newSearchPaths()
-
- systemUserDirLevel := len(strings.Split(resolvedUnitDirAdminUser, string(os.PathSeparator)))
- nonNumericFilter := getNonNumericFilter(resolvedUnitDirAdminUser, systemUserDirLevel)
-
- runtimeDir, found := os.LookupEnv("XDG_RUNTIME_DIR")
- if found {
- appendSubPaths(rootlessPaths, path.Join(runtimeDir, "containers/systemd"), false, nil)
- }
- appendSubPaths(rootlessPaths, path.Join(configDir, "containers/systemd"), false, nil)
- appendSubPaths(rootlessPaths, filepath.Join(quadlet.UnitDirAdmin, "users"), true, nonNumericFilter)
- appendSubPaths(rootlessPaths, filepath.Join(quadlet.UnitDirAdmin, "users", u.Uid), true, userLevelFilter)
-
- unitDirs = getUnitDirs(true)
- assert.Equal(t, rootlessPaths.sorted, unitDirs, "rootless unit dirs should match")
-
- // Test that relative path returns an empty list
- t.Setenv("QUADLET_UNIT_DIRS", "./relative/path")
- unitDirs = getUnitDirs(false)
- assert.Equal(t, []string{}, unitDirs)
-
- name := t.TempDir()
- t.Setenv("QUADLET_UNIT_DIRS", name)
- unitDirs = getUnitDirs(false)
- assert.Equal(t, []string{name}, unitDirs, "rootful should use environment variable")
-
- unitDirs = getUnitDirs(true)
- assert.Equal(t, []string{name}, unitDirs, "rootless should use environment variable")
-
- symLinkTestBaseDir := t.TempDir()
-
- actualDir := filepath.Join(symLinkTestBaseDir, "actual")
- err = os.Mkdir(actualDir, 0755)
- assert.NoError(t, err)
- innerDir := filepath.Join(actualDir, "inner")
- err = os.Mkdir(innerDir, 0755)
- assert.NoError(t, err)
- symlink := filepath.Join(symLinkTestBaseDir, "symlink")
- err = os.Symlink(actualDir, symlink)
- assert.NoError(t, err)
- t.Setenv("QUADLET_UNIT_DIRS", symlink)
- unitDirs = getUnitDirs(true)
- assert.Equal(t, []string{actualDir, innerDir}, unitDirs, "directory resolution should follow symlink")
-
- // Make a more elborate test with the following structure:
- // /linkToDir - real directory to link to
- // /linkToDir/a - real directory
- // /linkToDir/b - link to /unitDir/b/a should be ignored
- // /linkToDir/c - link to /unitDir should be ignored
- // /unitDir - start from here
- // /unitDir/a - real directory
- // /unitDir/a/a - real directory
- // /unitDir/a/a/a - real directory
- // /unitDir/b/a - real directory
- // /unitDir/b/b - link to /unitDir/a/a should be ignored
- // /unitDir/c - link to /linkToDir
- createDir := func(path, name string, dirs []string) (string, []string) {
- dirName := filepath.Join(path, name)
- assert.NotContains(t, dirs, dirName)
- err = os.Mkdir(dirName, 0755)
- assert.NoError(t, err)
- dirs = append(dirs, dirName)
- return dirName, dirs
- }
-
- linkDir := func(path, name, target string) {
- linkName := filepath.Join(path, name)
- err = os.Symlink(target, linkName)
- assert.NoError(t, err)
- }
-
- symLinkRecursiveTestBaseDir := t.TempDir()
-
- expectedDirs := make([]string, 0)
- // Create /unitDir
- unitsDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "unitsDir", expectedDirs)
- // Create /unitDir/a
- aDirPath, expectedDirs := createDir(unitsDirPath, "a", expectedDirs)
- // Create /unitDir/a/a
- aaDirPath, expectedDirs := createDir(aDirPath, "a", expectedDirs)
- // Create /unitDir/a/a/a
- _, expectedDirs = createDir(aaDirPath, "a", expectedDirs)
- // Create /unitDir/a/b
- _, expectedDirs = createDir(aDirPath, "b", expectedDirs)
- // Create /unitDir/b
- bDirPath, expectedDirs := createDir(unitsDirPath, "b", expectedDirs)
- // Create /unitDir/b/a
- baDirPath, expectedDirs := createDir(bDirPath, "a", expectedDirs)
- // Create /linkToDir
- linkToDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "linkToDir", expectedDirs)
- // Create /linkToDir/a
- _, expectedDirs = createDir(linkToDirPath, "a", expectedDirs)
-
- // Link /unitDir/b/b to /unitDir/a/a
- linkDir(bDirPath, "b", aaDirPath)
- // Link /linkToDir/b to /unitDir/b/a
- linkDir(linkToDirPath, "b", baDirPath)
- // Link /linkToDir/c to /unitDir
- linkDir(linkToDirPath, "c", unitsDirPath)
- // Link /unitDir/c to /linkToDir
- linkDir(unitsDirPath, "c", linkToDirPath)
-
- t.Setenv("QUADLET_UNIT_DIRS", unitsDirPath)
- unitDirs = getUnitDirs(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)
-
- // because chroot is only available for root,
- // unshare the namespace and map user to root
- c := exec.Command("/proc/self/exe", os.Args[1:]...)
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
- c.SysProcAttr = &syscall.SysProcAttr{
- Cloneflags: syscall.CLONE_NEWUSER,
- UidMappings: []syscall.SysProcIDMap{
- {
- ContainerID: 0,
- HostID: uidInt,
- Size: 1,
- },
- },
- }
- c.Env = append(os.Environ(), "_UNSHARED=true")
- err = c.Run()
- assert.NoError(t, err)
- } else {
- fmt.Println(os.Args)
-
- symLinkTestBaseDir := t.TempDir()
- rootF, err := os.Open("/")
- assert.NoError(t, err)
- defer rootF.Close()
- defer func() {
- err := rootF.Chdir()
- assert.NoError(t, err)
- err = syscall.Chroot(".")
- assert.NoError(t, err)
- }()
- err = syscall.Chroot(symLinkTestBaseDir)
- assert.NoError(t, err)
-
- err = os.MkdirAll(quadlet.UnitDirAdmin, 0755)
- assert.NoError(t, err)
- err = os.RemoveAll(quadlet.UnitDirAdmin)
- assert.NoError(t, err)
-
- createDir := func(path, name string) string {
- dirName := filepath.Join(path, name)
- err = os.Mkdir(dirName, 0755)
- assert.NoError(t, err)
- return dirName
- }
-
- linkDir := func(path, name, target string) {
- linkName := filepath.Join(path, name)
- err = os.Symlink(target, linkName)
- assert.NoError(t, err)
- }
-
- systemdDir := createDir("/", "systemd")
- userDir := createDir("/", "users")
- linkDir(systemdDir, "users", userDir)
- linkDir(quadlet.UnitDirAdmin, "", systemdDir)
-
- uidDir := createDir(userDir, u.Uid)
- uidDir2 := createDir(userDir, strconv.Itoa(uidInt+1))
- userInternalDir := createDir(userDir, "internal")
-
- // Make sure QUADLET_UNIT_DIRS is not set
- t.Setenv("QUADLET_UNIT_DIRS", "")
- // Test Rootful
- unitDirs := getUnitDirs(false)
- assert.NotContains(t, unitDirs, userDir, "rootful should not contain rootless")
- assert.NotContains(t, unitDirs, userInternalDir, "rootful should not contain rootless")
-
- // Test Rootless
- unitDirs = getUnitDirs(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/docs/source/Commands.rst b/docs/source/Commands.rst
index 29a41b8e33..b71c301fff 100644
--- a/docs/source/Commands.rst
+++ b/docs/source/Commands.rst
@@ -81,6 +81,8 @@ Commands
:doc:`push ` Push an image to a specified destination
+:doc:`quadlet ` Allows users to manage Quadlets
+
:doc:`rename ` Rename an existing container
:doc:`restart ` Restart one or more containers
diff --git a/docs/source/markdown/podman-quadlet-install.1.md b/docs/source/markdown/podman-quadlet-install.1.md
new file mode 100644
index 0000000000..d466a7981b
--- /dev/null
+++ b/docs/source/markdown/podman-quadlet-install.1.md
@@ -0,0 +1,47 @@
+% podman-quadlet-install 1
+
+## NAME
+podman\-quadlet\-install - Install a quadlet file or quadlet application
+
+## SYNOPSIS
+**podman quadlet install** [*options*] *quadlet-path-or-url* [*files-path-or-url*]...
+
+## DESCRIPTION
+
+Install a Quadlet file or an application (which may include multiple Quadlet files) for the current user. You can specify Quadlet files as local files or web URLs.
+
+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.
+
+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.
+
+## OPTIONS
+
+#### **--reload-systemd**
+
+Reload systemd after installing Quadlets (default true).
+
+## EXAMPLES
+
+Install quadlet from a file.
+
+```
+$ podman quadlet install /path/to/myquadlet.container
+```
+Install quadlet from a dir.
+
+```
+$ podman quadlet install /path/to/dir/
+```
+
+Install quadlet from a url
+```
+$ podman quadlet install https://github.com/containers/podman/blob/main/test/e2e/quadlet/basic.container
+```
+
+## SEE ALSO
+**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**
diff --git a/docs/source/markdown/podman-quadlet-list.1.md b/docs/source/markdown/podman-quadlet-list.1.md
new file mode 100644
index 0000000000..cbf83c44e9
--- /dev/null
+++ b/docs/source/markdown/podman-quadlet-list.1.md
@@ -0,0 +1,55 @@
+% podman-quadlet-list 1
+
+## NAME
+podman\-quadlet\-list - List installed quadlets
+
+## SYNOPSIS
+**podman quadlet list** [*options*]
+
+## DESCRIPTION
+
+List all Quadlets configured for the current user.
+
+## OPTIONS
+
+#### **--filter**, **-f**
+
+Filter output based on conditions give.
+
+The *filters* argument format is of `key=value`. If there is more than one *filter*, then pass multiple OPTIONS: **--filter** *foo=bar* **--filter** *bif=baz*.
+
+Supported filters:
+
+| Filter | Description |
+|------------|--------------------------------------------------------------------------------------------------|
+| name | Filter by quadlet name. |
+
+#### **--format**
+
+Pretty-print output to JSON or using a Go template (default "{{range .}}{{.Name}}\t{{.UnitName}}\t{{.Path}}\t{{.Status}}\n{{end -}}")
+
+Print results with a Go template.
+
+| **Placeholder** | **Description** |
+|-----------------|--------------------------------------------------|
+| .App | Name of application if Quadlet is part of an app |
+| .Name | Name of the Quadlet file |
+| .Path | Quadlet file path on disk |
+| .Status | Quadlet status corresponding to systemd unit |
+| .UnitName | Systemd unit name corresponding to quadlet |
+
+## EXAMPLES
+
+Filter list by name.
+
+```
+$ podman quadlet list --filter 'name=test*'
+```
+
+Format list output for a specific field.
+```
+$ podman quadlet list --format '{{ .Unit }}'
+```
+
+## SEE ALSO
+**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**
diff --git a/docs/source/markdown/podman-quadlet-print.1.md b/docs/source/markdown/podman-quadlet-print.1.md
new file mode 100644
index 0000000000..848570b9c0
--- /dev/null
+++ b/docs/source/markdown/podman-quadlet-print.1.md
@@ -0,0 +1,20 @@
+% podman-quadlet-print 1
+
+## NAME
+podman\-quadlet\-print - Display the contents of a quadlet
+
+## SYNOPSIS
+**podman quadlet print** *quadlet-name*
+
+## DESCRIPTION
+
+Print the contents of a Quadlet, displaying the file including all comments.
+
+## EXAMPLES
+
+```
+$ podman quadlet print myquadlet.container
+```
+
+## SEE ALSO
+**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**
diff --git a/docs/source/markdown/podman-quadlet-rm.1.md b/docs/source/markdown/podman-quadlet-rm.1.md
new file mode 100644
index 0000000000..ca82e074a6
--- /dev/null
+++ b/docs/source/markdown/podman-quadlet-rm.1.md
@@ -0,0 +1,43 @@
+% podman-quadlet-rm 1
+
+## NAME
+podman\-quadlet\-rm - Removes an installed quadlet
+
+## SYNOPSIS
+**podman quadlet rm** [*options*] *quadlet-name* [*quadlet-name*]...
+
+## DESCRIPTION
+
+Remove one or more installed Quadlets from the current user. Following command also takes application name
+as input and removes all the Quadlets which belongs to that specific 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.
+
+## OPTIONS
+
+#### **--all**, **-a**
+
+Remove all Quadlets for the current user.
+
+#### **--force**, **-f**
+
+Remove running quadlets.
+
+#### **--ignore**, **-i**
+
+Do not error for Quadlets that do not exist.
+
+#### **--reload-systemd**
+
+Reload systemd after removal
+
+## EXAMPLES
+
+```
+$ podman quadlet rm myquadlet.container
+```
+
+## SEE ALSO
+**[podman(1)](podman.1.md)**, **[podman-quadlet(1)](podman-quadlet.1.md)**
diff --git a/docs/source/markdown/podman-quadlet.1.md b/docs/source/markdown/podman-quadlet.1.md
new file mode 100644
index 0000000000..f0a0327df9
--- /dev/null
+++ b/docs/source/markdown/podman-quadlet.1.md
@@ -0,0 +1,25 @@
+% podman-quadlet 1
+
+## NAME
+podman\-quadlet - Allows users to manage Quadlets
+
+## SYNOPSIS
+**podman quadlet** *subcommand*
+
+## DESCRIPTION
+`podman quadlet` is a set of subcommands that manage Quadlets.
+
+Podman Quadlets allow users to manage containers, pods, volumes, networks, and images declaratively via systemd unit files, streamlining container management on Linux systems without the complexity of full orchestration tools like Kubernetes
+
+
+## SUBCOMMANDS
+
+| Command | Man Page | Description |
+|---------|------------------------------------------------------------|--------------------------------------------------------------|
+| install | [podman-quadlet-install(1)](podman-quadlet-install.1.md) | Install a quadlet file or quadlet application |
+| list | [podman-quadlet-list(1)](podman-quadlet-list.1.md) | List installed quadlets |
+| print | [podman-quadlet-print(1)](podman-quadlet-print.1.md) | Display the contents of a quadlet |
+| rm | [podman-quadlet-rm(1)](podman-quadlet-rm.1.md) | Removes an installed quadlet |
+
+## SEE ALSO
+**[podman(1)](podman.1.md)**
diff --git a/docs/source/markdown/podman.1.md b/docs/source/markdown/podman.1.md
index f7a919d1ee..e74d9b42e1 100644
--- a/docs/source/markdown/podman.1.md
+++ b/docs/source/markdown/podman.1.md
@@ -371,6 +371,7 @@ the exit codes follow the `chroot` standard, see below:
| [podman-ps(1)](podman-ps.1.md) | Print out information about containers. |
| [podman-pull(1)](podman-pull.1.md) | Pull an image from a registry. |
| [podman-push(1)](podman-push.1.md) | Push an image, manifest list or image index from local storage to elsewhere. |
+| [podman-quadlet(1)](podman-quadlet.1.md) | Allows users to manage Quadlets. |
| [podman-rename(1)](podman-rename.1.md) | Rename an existing container. |
| [podman-restart(1)](podman-restart.1.md) | Restart one or more containers. |
| [podman-rm(1)](podman-rm.1.md) | Remove one or more containers. |
diff --git a/pkg/domain/entities/engine_container.go b/pkg/domain/entities/engine_container.go
index 2f3516ed4b..8af843bb97 100644
--- a/pkg/domain/entities/engine_container.go
+++ b/pkg/domain/entities/engine_container.go
@@ -93,6 +93,10 @@ type ContainerEngine interface { //nolint:interfacebloat
PodStop(ctx context.Context, namesOrIds []string, options PodStopOptions) ([]*PodStopReport, error)
PodTop(ctx context.Context, options PodTopOptions) (*StringSliceReport, error)
PodUnpause(ctx context.Context, namesOrIds []string, options PodunpauseOptions) ([]*PodUnpauseReport, error)
+ QuadletInstall(ctx context.Context, pathsOrURLs []string, options QuadletInstallOptions) (*QuadletInstallReport, error)
+ QuadletList(ctx context.Context, options QuadletListOptions) ([]*ListQuadlet, error)
+ QuadletPrint(ctx context.Context, quadlet string) (string, error)
+ QuadletRemove(ctx context.Context, quadlets []string, options QuadletRemoveOptions) (*QuadletRemoveReport, error)
Renumber(ctx context.Context) error
Reset(ctx context.Context) error
SetupRootless(ctx context.Context, noMoveProcess bool, cgroupMode string) error
diff --git a/pkg/domain/entities/quadlet.go b/pkg/domain/entities/quadlet.go
new file mode 100644
index 0000000000..177433232e
--- /dev/null
+++ b/pkg/domain/entities/quadlet.go
@@ -0,0 +1,62 @@
+package entities
+
+// QuadletInstallOptions contains options to the `podman quadlet install` command
+type QuadletInstallOptions struct {
+ // Whether to reload systemd after installation is completed
+ ReloadSystemd bool
+}
+
+// QuadletInstallReport contains the output of the `quadlet install` command
+// including what files were successfully installed (and to where), and what
+// files errored (and why).
+type QuadletInstallReport struct {
+ // InstalledQuadlets is a map of the path of the quadlet file to be installed
+ // to where it was installed to.
+ InstalledQuadlets map[string]string
+ // QuadletErrors is a map of the path of the quadlet file to be installed
+ // to the error that occurred attempting to install it
+ QuadletErrors map[string]error
+}
+
+// QuadletListOptions contains options to the `podman quadlet list` command.
+type QuadletListOptions struct {
+ // Filters contains filters that will limit what Quadlets are displayed
+ Filters []string
+}
+
+// A ListQuadlet is a single Quadlet to be listed by `podman quadlet list`
+type ListQuadlet struct {
+ // Name is the name of the Quadlet file
+ Name string
+ // UnitName is the name of the systemd unit created from the Quadlet.
+ // May be empty if systemd has not be reloaded since it was installed.
+ UnitName string
+ // Path to the Quadlet on disk
+ Path string
+ // What is the status of the Quadlet - if present in systemd, will be a
+ // systemd status, else will mention if the Quadlet has syntax errors
+ Status string
+ // If multiple quadlets were installed together they will belong
+ // to common App.
+ App string
+}
+
+// QuadletRemoveOptions contains parameters for removing Quadlets
+type QuadletRemoveOptions struct {
+ // Force indicates that running quadlets should be removed as well
+ Force bool
+ // All indicates all quadlets should be removed
+ All bool
+ // Ignore indicates that missing quadlets should not cause an error
+ Ignore bool
+ // ReloadSystemd determines whether systemd will be reloaded after the Quadlet is removed.
+ ReloadSystemd bool
+}
+
+// QuadletRemoveReport contains the results of an operation to remove obe or more quadlets
+type QuadletRemoveReport struct {
+ // Removed is a list of quadlets that were successfully removed
+ Removed []string
+ // Errors is a map of Quadlet name to error that occurred during removal.
+ Errors map[string]error
+}
diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go
new file mode 100644
index 0000000000..1906a2f4f8
--- /dev/null
+++ b/pkg/domain/infra/abi/quadlet.go
@@ -0,0 +1,699 @@
+//go:build !remote
+// +build !remote
+
+package abi
+
+import (
+ "bufio"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "net/http"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/containers/podman/v5/pkg/domain/entities"
+ "github.com/containers/podman/v5/pkg/rootless"
+ "github.com/containers/podman/v5/pkg/systemd"
+ "github.com/containers/podman/v5/pkg/systemd/parser"
+ systemdquadlet "github.com/containers/podman/v5/pkg/systemd/quadlet"
+ "github.com/containers/podman/v5/pkg/util"
+ "github.com/coreos/go-systemd/v22/dbus"
+ "github.com/sirupsen/logrus"
+)
+
+// 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())
+ if _, err := os.Stat(filepath.Join(installDir, assetFilename)); errors.Is(err, fs.ErrNotExist) {
+ // Asset file does not exist, just return
+ return nil
+ }
+ result, err := getListFromFile(filepath.Join(installDir, assetFilename))
+ if err != nil {
+ return nil
+ }
+ for _, entry := range result {
+ os.Remove(filepath.Join(installDir, entry))
+ }
+ defer os.Remove(filepath.Join(installDir, assetFilename))
+ return nil
+}
+
+func getListFromFile(path string) ([]string, error) {
+ result := []string{}
+ file, err := os.Open(path)
+ if err != nil {
+ return result, fmt.Errorf("could not open asset file: %v", err)
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ filename := strings.TrimSpace(scanner.Text())
+ if filename == "" {
+ continue
+ }
+ result = append(result, filename)
+ }
+ if err := scanner.Err(); err != nil {
+ return result, fmt.Errorf("error reading asset file: %v", 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?
+ // We cannot proceed if not.
+ conn, err := systemd.ConnectToDBUS()
+ if err != nil {
+ return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
+ }
+
+ // Is Quadlet installed? No point if not.
+ quadletPath := "/usr/lib/systemd/system-generators/podman-system-generator"
+ quadletStat, err := os.Stat(quadletPath)
+ if err != nil {
+ return nil, fmt.Errorf("cannot stat Quadlet generator, Quadlet may not be installed: %w", err)
+ }
+
+ if !quadletStat.Mode().IsRegular() || quadletStat.Mode()&0100 == 0 {
+ return nil, fmt.Errorf("no valid Quadlet binary installed to %q, unable to use Quadlet", quadletPath)
+ }
+
+ installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())
+ logrus.Debugf("Going to install Quadlet to directory %s", installDir)
+
+ stat, err := os.Stat(installDir)
+ if rootless.IsRootless() {
+ // Make the directory if it doesn't exist
+ if err != nil && errors.Is(err, fs.ErrNotExist) {
+ if err := os.MkdirAll(installDir, 0755); err != nil {
+ return nil, fmt.Errorf("unable to create Quadlet install path %s: %w", installDir, err)
+ }
+ } else if err != nil {
+ return nil, fmt.Errorf("unable to stat Quadlet install path %s: %w", installDir, err)
+ }
+ } else {
+ // Package manager should have created the dir for root Podman.
+ // So just check that it exists.
+ if err != nil {
+ return nil, fmt.Errorf("unable to stat Quadlet install path %s: %w", installDir, err)
+ }
+ if !stat.IsDir() {
+ return nil, fmt.Errorf("install path %s for Quadlets is not a directory", installDir)
+ }
+ }
+
+ installReport := entities.QuadletInstallReport{
+ InstalledQuadlets: make(map[string]string),
+ QuadletErrors: make(map[string]error),
+ }
+
+ assetFile := ""
+ paths := pathsOrURLs
+ if !strings.HasPrefix(pathsOrURLs[0], "http://") && !strings.HasPrefix(pathsOrURLs[0], "https://") {
+ // Check if first path is dir, this is an APP
+ info, err := os.Stat(pathsOrURLs[0])
+ if err != nil {
+ fmt.Println("Error:", err)
+ return nil, fmt.Errorf("unable to stat Quadlet path %s: %w", pathsOrURLs[0], err)
+ }
+ if info.IsDir() {
+ // If it's a directory, then read all files and add it to paths
+ entries, err := os.ReadDir(pathsOrURLs[0])
+ if err != nil {
+ return nil, fmt.Errorf("unable to read Quadlet dir %s: %w", pathsOrURLs[0], err)
+ }
+ redoPaths := []string{}
+ for _, entry := range entries {
+ redoPaths = append(redoPaths, filepath.Join(pathsOrURLs[0], entry.Name()))
+ }
+ redoPaths = append(redoPaths, pathsOrURLs[1:]...)
+ paths = redoPaths
+ // treat all file in this session as part of one app.
+ assetFile = "." + filepath.Base(pathsOrURLs[0]) + ".app"
+ }
+ }
+
+ // Loop over all given URLs
+ for i, toInstall := range paths {
+ validateQuadletFile := false
+ if i == 0 && assetFile == "" {
+ assetFile = "." + filepath.Base(toInstall) + ".asset"
+ validateQuadletFile = true
+ }
+ switch {
+ case strings.HasPrefix(toInstall, "http://") || strings.HasPrefix(toInstall, "https://"):
+ r, err := http.Get(toInstall)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to download URL %s: %w", toInstall, err)
+ continue
+ }
+ quadletExtension := getFileExtension(r)
+ // It's a URL. Pull to temporary file.
+ tmpFile, err := os.CreateTemp("", "quadlet-dl-*"+quadletExtension)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file to download URL %s: %w", toInstall, err)
+ continue
+ }
+ defer func() {
+ tmpFile.Close()
+ if err := os.Remove(tmpFile.Name()); err != nil {
+ logrus.Errorf("Unable to remove temporary file %q: %v", tmpFile.Name(), err)
+ }
+ }()
+ defer r.Body.Close()
+ _, err = io.Copy(tmpFile, r.Body)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = fmt.Errorf("populating temporary file: %w", err)
+ continue
+ }
+ installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), "", installDir, assetFile, validateQuadletFile)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = err
+ continue
+ }
+ installReport.InstalledQuadlets[toInstall] = installedPath
+ default:
+ _, err := os.Stat(toInstall)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = err
+ continue
+ } else {
+ // If toInstall is a single file, execute the original logic
+ installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile)
+ if err != nil {
+ installReport.QuadletErrors[toInstall] = err
+ } else {
+ installReport.InstalledQuadlets[toInstall] = installedPath
+ }
+ }
+ }
+ }
+
+ // TODO: Should we still do this if the above validation errored?
+ if options.ReloadSystemd {
+ if err := conn.ReloadContext(ctx); err != nil {
+ return &installReport, fmt.Errorf("reloading systemd: %w", err)
+ }
+ }
+
+ return &installReport, nil
+}
+
+// Extracts file extension from Content-Disposition or URL
+func getFileExtension(resp *http.Response) string {
+ // Try Content-Disposition header first
+ cd := resp.Header.Get("Content-Disposition")
+ if cd != "" {
+ re := regexp.MustCompile(`(?i)filename="?([^"]+)"?`)
+ matches := re.FindStringSubmatch(cd)
+ if len(matches) > 1 {
+ return path.Ext(matches[1])
+ }
+ }
+ // Fallback: use URL path
+ ext := path.Ext(resp.Request.URL.Path)
+ return ext
+}
+
+// Install a single Quadlet from a path on local disk to the given install directory.
+// 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, validateQuadletFile bool) (string, error) {
+ // 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)
+ }
+
+ finalPath := filepath.Join(installDir, filepath.Base(filepath.Clean(path)))
+ if destName != "" {
+ finalPath = filepath.Join(installDir, destName)
+ }
+
+ // Second, validate that the dest path does NOT exist.
+ // TODO: Overwrite flag?
+ if _, err := os.Stat(finalPath); err == nil {
+ return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(finalPath))
+ }
+
+ // Validate extension is valid
+ if validateQuadletFile && !systemdquadlet.IsExtSupported(finalPath) {
+ return "", fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(finalPath))
+ }
+
+ // Move the file in
+ contents, err := os.ReadFile(path)
+ if err != nil {
+ return "", fmt.Errorf("reading source file %q: %w", path, err)
+ }
+ if err := os.WriteFile(finalPath, contents, 0644); err != nil {
+ return "", fmt.Errorf("writing Quadlet %q to %q: %w", path, finalPath, err)
+ }
+
+ // TODO: It would be nice to do single-file validation here, and remove the file if it fails.
+ if !validateQuadletFile {
+ err := util.AppendStringToFile(filepath.Join(installDir, assetFile), filepath.Base(filepath.Clean(path)))
+ if err != nil {
+ return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
+ }
+ }
+ return finalPath, nil
+}
+
+// buildAppMap scans the given directory for files that start with '.'
+// and end with '.app', reads their contents (one filename per line), and
+// returns a map where each filename maps to the .app file that contains it.
+// 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.Walk(dir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ name := info.Name()
+ if strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".app") {
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ filename := strings.TrimSpace(scanner.Text())
+ if filename != "" {
+ reverseMap[filename] = name
+ appMap[name] = append(appMap[name], filename)
+ }
+ }
+ if err := scanner.Err(); 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 {
+ // This is perfectly normal, some quadlet directories aren't created by the package
+ logrus.Infof("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
+}
+
+// Generate systemd service name for a Quadlet from full path to the Quadlet file
+func getQuadletServiceName(quadletPath string) (string, error) {
+ unit, err := parser.ParseUnitFile(quadletPath)
+ if err != nil {
+ return "", fmt.Errorf("parsing Quadlet file %s: %w", quadletPath, err)
+ }
+
+ serviceName, err := systemdquadlet.GetUnitServiceName(unit)
+ if err != nil {
+ return "", fmt.Errorf("generating service name for Quadlet %s: %w", filepath.Base(quadletPath), err)
+ }
+ return serviceName + ".service", nil
+}
+
+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
+ 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 := dbus.NewWithContext(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
+ }
+
+ 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, err := getQuadletServiceName(path)
+ if err != nil {
+ report.Status = err.Error()
+ reports = append(reports, &report)
+ continue
+ }
+ partialReports[serviceName] = report
+ allServiceNames = append(allServiceNames, serviceName)
+ }
+
+ // 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)
+ }
+
+ finalReports := make([]*entities.ListQuadlet, 0, len(reports))
+ for _, report := range reports {
+ include := true
+ for _, filterFunc := range filterFuncs {
+ include = filterFunc(report)
+ }
+ if include {
+ finalReports = append(finalReports, report)
+ }
+ }
+
+ return finalReports, nil
+}
+
+// Retrieve path to a Quadlet file given full name including extension
+func getQuadletPathByName(name string) (string, error) {
+ // Check if we were given a valid extension
+ if !systemdquadlet.IsExtSupported(name) {
+ return "", fmt.Errorf("%q is not a supported quadlet file type", filepath.Ext(name))
+ }
+
+ quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless())
+ for _, dir := range quadletDirs {
+ testPath := filepath.Join(dir, name)
+ if _, err := os.Stat(testPath); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ return "", fmt.Errorf("cannot stat quadlet at path %q: %w", testPath, err)
+ }
+ continue
+ }
+ return testPath, nil
+ }
+ return "", fmt.Errorf("could not locate quadlet %q in any supported quadlet directory", name)
+}
+
+func (ic *ContainerEngine) QuadletPrint(ctx context.Context, quadlet string) (string, error) {
+ quadletPath, err := getQuadletPathByName(quadlet)
+ if err != nil {
+ return "", err
+ }
+
+ contents, err := os.ReadFile(quadletPath)
+ if err != nil {
+ return "", fmt.Errorf("reading quadlet %q contents: %w", quadletPath, err)
+ }
+
+ return string(contents), nil
+}
+
+func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, options entities.QuadletRemoveOptions) (*entities.QuadletRemoveReport, error) {
+ report := entities.QuadletRemoveReport{
+ 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)
+ }
+ quadletsRe := []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 {
+ quadletsRe = append(quadletsRe, file)
+ }
+ }
+ }
+ } else {
+ quadletsRe = append(quadletsRe, quadlet)
+ }
+ }
+ quadlets = quadletsRe
+ allQuadletPaths := make([]string, 0, len(quadlets))
+ allServiceNames := make([]string, 0, len(quadlets))
+ runningQuadlets := make([]string, 0, len(quadlets))
+ serviceNameToQuadletName := make(map[string]string)
+ needReload := false
+
+ // Early escape: if 0 quadlets are requested, bail immediately without error.
+ 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.
+ conn, err := systemd.ConnectToDBUS()
+ if err != nil {
+ return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
+ }
+
+ if options.All {
+ allQuadlets := getAllQuadletPaths()
+ quadlets = allQuadlets
+ }
+
+ 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 !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
+ }
+ continue
+ }
+ value, ok := reverseMap[quadlet]
+ if ok {
+ appFilePath := filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), value)
+ filesToRemove, err := getListFromFile(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)
+ }
+ }
+ }
+
+ 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]
+
+ if unitStatus.LoadState != "loaded" {
+ // Nothing to do here if it doesn't exist in systemd
+ continue
+ }
+ needReload = true
+ if unitStatus.ActiveState == "active" {
+ if !options.Force {
+ report.Errors[quadletName] = fmt.Errorf("quadlet %s is running and force is not set, refusing to remove", quadletName)
+ 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
+ }
+ }
+ }
+ }
+
+ // 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
+ }
+ }
+ 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 err := conn.ReloadContext(ctx); err != nil {
+ return &report, fmt.Errorf("reloading systemd: %w", err)
+ }
+ }
+
+ return &report, nil
+}
diff --git a/pkg/domain/infra/tunnel/quadlet.go b/pkg/domain/infra/tunnel/quadlet.go
new file mode 100644
index 0000000000..815176fde1
--- /dev/null
+++ b/pkg/domain/infra/tunnel/quadlet.go
@@ -0,0 +1,26 @@
+package tunnel
+
+import (
+ "context"
+ "errors"
+
+ "github.com/containers/podman/v5/pkg/domain/entities"
+)
+
+var errNotImplemented = errors.New("not implemented for the remote Podman client")
+
+func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []string, options entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) {
+ return nil, errNotImplemented
+}
+
+func (ic *ContainerEngine) QuadletList(ctx context.Context, options entities.QuadletListOptions) ([]*entities.ListQuadlet, error) {
+ return nil, errNotImplemented
+}
+
+func (ic *ContainerEngine) QuadletPrint(ctx context.Context, quadlet string) (string, error) {
+ return "", errNotImplemented
+}
+
+func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, options entities.QuadletRemoveOptions) (*entities.QuadletRemoveReport, error) {
+ return nil, errNotImplemented
+}
diff --git a/pkg/logiface/logiface.go b/pkg/logiface/logiface.go
new file mode 100644
index 0000000000..d236f74f95
--- /dev/null
+++ b/pkg/logiface/logiface.go
@@ -0,0 +1,24 @@
+package logiface
+
+type Logger interface {
+ Errorf(format string, args ...interface{})
+ Debugf(format string, args ...interface{})
+}
+
+var logger Logger
+
+func SetLogger(l Logger) {
+ logger = l
+}
+
+func Errorf(format string, args ...interface{}) {
+ if logger != nil {
+ logger.Errorf(format, args...)
+ }
+}
+
+func Debugf(format string, args ...interface{}) {
+ if logger != nil {
+ logger.Debugf(format, args...)
+ }
+}
diff --git a/pkg/systemd/quadlet/quadlet.go b/pkg/systemd/quadlet/quadlet.go
index 2e59cc95f4..eaef67fbf1 100644
--- a/pkg/systemd/quadlet/quadlet.go
+++ b/pkg/systemd/quadlet/quadlet.go
@@ -207,18 +207,6 @@ type GroupInfo struct {
}
var (
- // Key: Extension
- // Value: Processing order for resource naming dependencies
- SupportedExtensions = map[string]int{
- ".container": 4,
- ".volume": 2,
- ".kube": 4,
- ".network": 2,
- ".image": 1,
- ".build": 3,
- ".pod": 5,
- }
-
URL = regexp.Delayed(`^((https?)|(git)://)|(github\.com/).+$`)
validPortRange = regexp.Delayed(`\d+(-\d+)?(/udp|/tcp)?$`)
@@ -1450,6 +1438,27 @@ func GetBuiltImageName(buildUnit *parser.UnitFile) string {
return ""
}
+func GetUnitServiceName(unit *parser.UnitFile) (string, error) {
+ switch {
+ case strings.HasSuffix(unit.Filename, ".container"):
+ return GetContainerServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".volume"):
+ return GetVolumeServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".kube"):
+ return GetKubeServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".network"):
+ return GetNetworkServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".image"):
+ return GetImageServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".build"):
+ return GetBuildServiceName(unit), nil
+ case strings.HasSuffix(unit.Filename, ".pod"):
+ return GetPodServiceName(unit), nil
+ default:
+ return "", fmt.Errorf("unsupported file type %q", unit.Filename)
+ }
+}
+
func GetContainerServiceName(podUnit *parser.UnitFile) string {
return getServiceName(podUnit, ContainerGroup, "")
}
diff --git a/pkg/systemd/quadlet/quadlet_common.go b/pkg/systemd/quadlet/quadlet_common.go
new file mode 100644
index 0000000000..b6100fcaf6
--- /dev/null
+++ b/pkg/systemd/quadlet/quadlet_common.go
@@ -0,0 +1,15 @@
+package quadlet
+
+var (
+ // Key: Extension
+ // Value: Processing order for resource naming dependencies
+ SupportedExtensions = map[string]int{
+ ".container": 4,
+ ".volume": 2,
+ ".kube": 4,
+ ".network": 2,
+ ".image": 1,
+ ".build": 3,
+ ".pod": 5,
+ }
+)
diff --git a/pkg/systemd/quadlet/unitdirs.go b/pkg/systemd/quadlet/unitdirs.go
new file mode 100644
index 0000000000..44d20f4b26
--- /dev/null
+++ b/pkg/systemd/quadlet/unitdirs.go
@@ -0,0 +1,248 @@
+package quadlet
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "os/user"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "github.com/containers/podman/v5/pkg/logiface"
+)
+
+// This returns whether a file has an extension recognized as a valid Quadlet unit type.
+func IsExtSupported(filename string) bool {
+ ext := filepath.Ext(filename)
+ _, ok := SupportedExtensions[ext]
+ return ok
+}
+
+// This returns default install paths for .Quadlet files for `rootless` and `root` user.
+// Defaults to `/etc/containers/systemd` for root user and `$XDG_CONFIG_HOME/containers/systemd`
+// for rootless users.
+func GetInstallUnitDirPath(rootless bool) string {
+ if rootless {
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: %v", err)
+ return ""
+ }
+ return path.Join(configDir, "containers/systemd")
+ }
+ return UnitDirAdmin
+}
+
+// This returns the directories where we read quadlet .container and .volumes from
+// 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, and $XDG_CONFIG_HOME/containers/systemd
+func GetUnitDirs(rootless bool) []string {
+ paths := NewSearchPaths()
+
+ // Allow overriding source dir, this is mainly for the CI tests
+ if getDirsFromEnv(paths) {
+ return paths.sorted
+ }
+
+ resolvedUnitDirAdminUser := ResolveUnitDirAdminUser()
+ userLevelFilter := GetUserLevelFilter(resolvedUnitDirAdminUser)
+
+ if rootless {
+ systemUserDirLevel := len(strings.Split(resolvedUnitDirAdminUser, string(os.PathSeparator)))
+ nonNumericFilter := GetNonNumericFilter(resolvedUnitDirAdminUser, systemUserDirLevel)
+ getRootlessDirs(paths, nonNumericFilter, userLevelFilter)
+ } else {
+ getRootDirs(paths, userLevelFilter)
+ }
+ return paths.sorted
+}
+
+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{}
+}
+
+func NewSearchPaths() *searchPaths {
+ return &searchPaths{
+ sorted: make([]string, 0),
+ visitedDirs: make(map[string]struct{}, 0),
+ }
+}
+
+func (s *searchPaths) Add(path string) {
+ s.sorted = append(s.sorted, path)
+ s.visitedDirs[path] = struct{}{}
+}
+
+func (s *searchPaths) GetSortedPaths() []string {
+ return s.sorted
+}
+
+func (s *searchPaths) Visited(path string) bool {
+ _, visited := s.visitedDirs[path]
+ return visited
+}
+
+func getDirsFromEnv(paths *searchPaths) bool {
+ unitDirsEnv := os.Getenv("QUADLET_UNIT_DIRS")
+ if len(unitDirsEnv) == 0 {
+ return false
+ }
+
+ for _, eachUnitDir := range strings.Split(unitDirsEnv, ":") {
+ if !filepath.IsAbs(eachUnitDir) {
+ logiface.Errorf("%s not a valid file path", eachUnitDir)
+ break
+ }
+ AppendSubPaths(paths, eachUnitDir, false, nil)
+ }
+ return true
+}
+
+func AppendSubPaths(paths *searchPaths, path string, isUserFlag bool, filterPtr func(string, bool) bool) {
+ resolvedPath, err := filepath.EvalSymlinks(path)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ logiface.Debugf("Error occurred resolving path %q: %s", path, err)
+ }
+ // Despite the failure add the path to the list for logging purposes
+ // This is the equivalent of adding the path when info==nil below
+ paths.Add(path)
+ return
+ }
+
+ if skipPath(paths, resolvedPath, isUserFlag, filterPtr) {
+ return
+ }
+
+ // Add the current directory
+ paths.Add(resolvedPath)
+
+ // Read the contents of the directory
+ entries, err := os.ReadDir(resolvedPath)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ logiface.Debugf("Error occurred walking sub directories %q: %s", path, err)
+ }
+ return
+ }
+
+ // Recursively run through the contents of the directory
+ for _, entry := range entries {
+ fullPath := filepath.Join(resolvedPath, entry.Name())
+ AppendSubPaths(paths, fullPath, isUserFlag, filterPtr)
+ }
+}
+
+func skipPath(paths *searchPaths, path string, isUserFlag bool, filterPtr func(string, bool) bool) bool {
+ // If the path is already in the map no need to read it again
+ if paths.Visited(path) {
+ return true
+ }
+
+ // Don't traverse drop-in directories
+ if strings.HasSuffix(path, ".d") {
+ return true
+ }
+
+ // Check if the directory should be filtered out
+ if filterPtr != nil && !filterPtr(path, isUserFlag) {
+ return true
+ }
+
+ stat, err := os.Stat(path)
+ if err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ logiface.Debugf("Error occurred resolving path %q: %s", path, err)
+ }
+ return true
+ }
+
+ // Not a directory nothing to add
+ return !stat.IsDir()
+}
+
+func ResolveUnitDirAdminUser() string {
+ unitDirAdminUser := filepath.Join(UnitDirAdmin, "users")
+ var err error
+ var resolvedUnitDirAdminUser string
+ if resolvedUnitDirAdminUser, err = filepath.EvalSymlinks(unitDirAdminUser); err != nil {
+ if !errors.Is(err, fs.ErrNotExist) {
+ logiface.Debugf("Error occurred resolving path %q: %s", unitDirAdminUser, err)
+ }
+ resolvedUnitDirAdminUser = unitDirAdminUser
+ }
+ return resolvedUnitDirAdminUser
+}
+
+func GetUserLevelFilter(resolvedUnitDirAdminUser string) func(string, bool) bool {
+ return func(_path string, isUserFlag bool) bool {
+ // if quadlet generator is run rootless, do not recurse other user sub dirs
+ // if quadlet generator is run as root, ignore users sub dirs
+ if strings.HasPrefix(_path, resolvedUnitDirAdminUser) {
+ if isUserFlag {
+ return true
+ }
+ } else {
+ return true
+ }
+ return false
+ }
+}
+
+func GetNonNumericFilter(resolvedUnitDirAdminUser string, systemUserDirLevel int) func(string, bool) bool {
+ return func(path string, isUserFlag bool) bool {
+ // when running in rootless, recursive walk directories that are non numeric
+ // ignore sub dirs under the `users` directory which correspond to a user id
+ if strings.HasPrefix(path, resolvedUnitDirAdminUser) {
+ listDirUserPathLevels := strings.Split(path, string(os.PathSeparator))
+ // Make sure to add the base directory
+ if len(listDirUserPathLevels) == systemUserDirLevel {
+ return true
+ }
+ if len(listDirUserPathLevels) > systemUserDirLevel {
+ if !(regexp.MustCompile(`^[0-9]*$`).MatchString(listDirUserPathLevels[systemUserDirLevel])) {
+ return true
+ }
+ }
+ } else {
+ return true
+ }
+ return false
+ }
+}
+
+func getRootlessDirs(paths *searchPaths, nonNumericFilter, userLevelFilter func(string, bool) bool) {
+ runtimeDir, found := os.LookupEnv("XDG_RUNTIME_DIR")
+ if found {
+ AppendSubPaths(paths, path.Join(runtimeDir, "containers/systemd"), false, nil)
+ }
+
+ configDir, err := os.UserConfigDir()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Warning: %v", err)
+ return
+ }
+ AppendSubPaths(paths, path.Join(configDir, "containers/systemd"), false, nil)
+
+ u, err := user.Current()
+ if err == nil {
+ AppendSubPaths(paths, filepath.Join(UnitDirAdmin, "users"), true, nonNumericFilter)
+ AppendSubPaths(paths, filepath.Join(UnitDirAdmin, "users", u.Uid), true, userLevelFilter)
+ } else {
+ fmt.Fprintf(os.Stderr, "Warning: %v", err)
+ // Add the base directory even if the UID was not found
+ paths.Add(filepath.Join(UnitDirAdmin, "users"))
+ }
+}
+
+func getRootDirs(paths *searchPaths, userLevelFilter func(string, bool) bool) {
+ AppendSubPaths(paths, UnitDirTemp, false, userLevelFilter)
+ AppendSubPaths(paths, UnitDirAdmin, false, userLevelFilter)
+ AppendSubPaths(paths, UnitDirDistro, false, nil)
+}
diff --git a/pkg/systemd/quadlet/unitdirs_test.go b/pkg/systemd/quadlet/unitdirs_test.go
new file mode 100644
index 0000000000..6ca785dc0b
--- /dev/null
+++ b/pkg/systemd/quadlet/unitdirs_test.go
@@ -0,0 +1,223 @@
+//go:build linux
+
+package quadlet
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestUnitDirs(t *testing.T) {
+ u, err := user.Current()
+ assert.NoError(t, err)
+ uidInt, err := strconv.Atoi(u.Uid)
+ assert.NoError(t, err)
+
+ if os.Getenv("_UNSHARED") != "true" {
+ unitDirs := GetUnitDirs(false)
+
+ resolvedUnitDirAdminUser := ResolveUnitDirAdminUser()
+ userLevelFilter := GetUserLevelFilter(resolvedUnitDirAdminUser)
+ rootfulPaths := NewSearchPaths()
+ AppendSubPaths(rootfulPaths, UnitDirTemp, false, userLevelFilter)
+ AppendSubPaths(rootfulPaths, UnitDirAdmin, false, userLevelFilter)
+ AppendSubPaths(rootfulPaths, UnitDirDistro, false, userLevelFilter)
+ assert.Equal(t, rootfulPaths.GetSortedPaths(), unitDirs, "rootful unit dirs should match")
+
+ configDir, err := os.UserConfigDir()
+ assert.NoError(t, err)
+
+ rootlessPaths := NewSearchPaths()
+
+ systemUserDirLevel := len(strings.Split(resolvedUnitDirAdminUser, string(os.PathSeparator)))
+ nonNumericFilter := GetNonNumericFilter(resolvedUnitDirAdminUser, systemUserDirLevel)
+
+ runtimeDir, found := os.LookupEnv("XDG_RUNTIME_DIR")
+ if found {
+ AppendSubPaths(rootlessPaths, path.Join(runtimeDir, "containers/systemd"), false, nil)
+ }
+ AppendSubPaths(rootlessPaths, path.Join(configDir, "containers/systemd"), false, nil)
+ AppendSubPaths(rootlessPaths, filepath.Join(UnitDirAdmin, "users"), true, nonNumericFilter)
+ AppendSubPaths(rootlessPaths, filepath.Join(UnitDirAdmin, "users", u.Uid), true, userLevelFilter)
+
+ unitDirs = GetUnitDirs(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)
+ assert.Equal(t, []string{}, unitDirs)
+
+ name := t.TempDir()
+ t.Setenv("QUADLET_UNIT_DIRS", name)
+ unitDirs = GetUnitDirs(false)
+ assert.Equal(t, []string{name}, unitDirs, "rootful should use environment variable")
+
+ unitDirs = GetUnitDirs(true)
+ assert.Equal(t, []string{name}, unitDirs, "rootless should use environment variable")
+
+ symLinkTestBaseDir := t.TempDir()
+
+ actualDir := filepath.Join(symLinkTestBaseDir, "actual")
+ err = os.Mkdir(actualDir, 0755)
+ assert.NoError(t, err)
+ innerDir := filepath.Join(actualDir, "inner")
+ err = os.Mkdir(innerDir, 0755)
+ assert.NoError(t, err)
+ symlink := filepath.Join(symLinkTestBaseDir, "symlink")
+ err = os.Symlink(actualDir, symlink)
+ assert.NoError(t, err)
+ t.Setenv("QUADLET_UNIT_DIRS", symlink)
+ unitDirs = GetUnitDirs(true)
+ assert.Equal(t, []string{actualDir, innerDir}, unitDirs, "directory resolution should follow symlink")
+
+ // Make a more elborate test with the following structure:
+ // /linkToDir - real directory to link to
+ // /linkToDir/a - real directory
+ // /linkToDir/b - link to /unitDir/b/a should be ignored
+ // /linkToDir/c - link to /unitDir should be ignored
+ // /unitDir - start from here
+ // /unitDir/a - real directory
+ // /unitDir/a/a - real directory
+ // /unitDir/a/a/a - real directory
+ // /unitDir/b/a - real directory
+ // /unitDir/b/b - link to /unitDir/a/a should be ignored
+ // /unitDir/c - link to /linkToDir
+ createDir := func(path, name string, dirs []string) (string, []string) {
+ dirName := filepath.Join(path, name)
+ assert.NotContains(t, dirs, dirName)
+ err = os.Mkdir(dirName, 0755)
+ assert.NoError(t, err)
+ dirs = append(dirs, dirName)
+ return dirName, dirs
+ }
+
+ linkDir := func(path, name, target string) {
+ linkName := filepath.Join(path, name)
+ err = os.Symlink(target, linkName)
+ assert.NoError(t, err)
+ }
+
+ symLinkRecursiveTestBaseDir := t.TempDir()
+
+ expectedDirs := make([]string, 0)
+ // Create /unitDir
+ unitsDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "unitsDir", expectedDirs)
+ // Create /unitDir/a
+ aDirPath, expectedDirs := createDir(unitsDirPath, "a", expectedDirs)
+ // Create /unitDir/a/a
+ aaDirPath, expectedDirs := createDir(aDirPath, "a", expectedDirs)
+ // Create /unitDir/a/a/a
+ _, expectedDirs = createDir(aaDirPath, "a", expectedDirs)
+ // Create /unitDir/a/b
+ _, expectedDirs = createDir(aDirPath, "b", expectedDirs)
+ // Create /unitDir/b
+ bDirPath, expectedDirs := createDir(unitsDirPath, "b", expectedDirs)
+ // Create /unitDir/b/a
+ baDirPath, expectedDirs := createDir(bDirPath, "a", expectedDirs)
+ // Create /linkToDir
+ linkToDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "linkToDir", expectedDirs)
+ // Create /linkToDir/a
+ _, expectedDirs = createDir(linkToDirPath, "a", expectedDirs)
+
+ // Link /unitDir/b/b to /unitDir/a/a
+ linkDir(bDirPath, "b", aaDirPath)
+ // Link /linkToDir/b to /unitDir/b/a
+ linkDir(linkToDirPath, "b", baDirPath)
+ // Link /linkToDir/c to /unitDir
+ linkDir(linkToDirPath, "c", unitsDirPath)
+ // Link /unitDir/c to /linkToDir
+ linkDir(unitsDirPath, "c", linkToDirPath)
+
+ t.Setenv("QUADLET_UNIT_DIRS", unitsDirPath)
+ unitDirs = GetUnitDirs(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)
+
+ // because chroot is only available for root,
+ // unshare the namespace and map user to root
+ c := exec.Command("/proc/self/exe", os.Args[1:]...)
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ c.SysProcAttr = &syscall.SysProcAttr{
+ Cloneflags: syscall.CLONE_NEWUSER,
+ UidMappings: []syscall.SysProcIDMap{
+ {
+ ContainerID: 0,
+ HostID: uidInt,
+ Size: 1,
+ },
+ },
+ }
+ c.Env = append(os.Environ(), "_UNSHARED=true")
+ err = c.Run()
+ assert.NoError(t, err)
+ } else {
+ fmt.Println(os.Args)
+
+ symLinkTestBaseDir := t.TempDir()
+ rootF, err := os.Open("/")
+ assert.NoError(t, err)
+ defer rootF.Close()
+ defer func() {
+ err := rootF.Chdir()
+ assert.NoError(t, err)
+ err = syscall.Chroot(".")
+ assert.NoError(t, err)
+ }()
+ err = syscall.Chroot(symLinkTestBaseDir)
+ assert.NoError(t, err)
+
+ err = os.MkdirAll(UnitDirAdmin, 0755)
+ assert.NoError(t, err)
+ err = os.RemoveAll(UnitDirAdmin)
+ assert.NoError(t, err)
+
+ createDir := func(path, name string) string {
+ dirName := filepath.Join(path, name)
+ err = os.Mkdir(dirName, 0755)
+ assert.NoError(t, err)
+ return dirName
+ }
+
+ linkDir := func(path, name, target string) {
+ linkName := filepath.Join(path, name)
+ err = os.Symlink(target, linkName)
+ assert.NoError(t, err)
+ }
+
+ systemdDir := createDir("/", "systemd")
+ userDir := createDir("/", "users")
+ linkDir(systemdDir, "users", userDir)
+ linkDir(UnitDirAdmin, "", systemdDir)
+
+ uidDir := createDir(userDir, u.Uid)
+ uidDir2 := createDir(userDir, strconv.Itoa(uidInt+1))
+ userInternalDir := createDir(userDir, "internal")
+
+ // Make sure QUADLET_UNIT_DIRS is not set
+ t.Setenv("QUADLET_UNIT_DIRS", "")
+ // Test Rootful
+ unitDirs := GetUnitDirs(false)
+ assert.NotContains(t, unitDirs, userDir, "rootful should not contain rootless")
+ assert.NotContains(t, unitDirs, userInternalDir, "rootful should not contain rootless")
+
+ // Test Rootless
+ unitDirs = GetUnitDirs(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/pkg/util/utils.go b/pkg/util/utils.go
index c386474678..70e71a6ce0 100644
--- a/pkg/util/utils.go
+++ b/pkg/util/utils.go
@@ -1264,3 +1264,16 @@ func ExecAddTERM(existingEnv []string, execEnvs map[string]string) {
execEnvs["TERM"] = "xterm"
}
+
+// AppendStringToFile appends the given text to the specified file.
+// If the file does not exist, it will be created with 0644 permissions.
+func AppendStringToFile(filePath, text string) error {
+ f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+
+ _, err = f.WriteString(text + "\n")
+ return err
+}
diff --git a/test/system/252-quadlet.bats b/test/system/252-quadlet.bats
index a3d95309dc..e8de03d9f4 100644
--- a/test/system/252-quadlet.bats
+++ b/test/system/252-quadlet.bats
@@ -1870,4 +1870,5 @@ EOF
run_podman rmi -i $image_tag
}
+
# vim: filetype=sh
diff --git a/test/system/253-podman-quadlet.bats b/test/system/253-podman-quadlet.bats
new file mode 100644
index 0000000000..d415867b2e
--- /dev/null
+++ b/test/system/253-podman-quadlet.bats
@@ -0,0 +1,323 @@
+#!/usr/bin/env bats -*- bats -*-
+#
+# Tests generated configurations for systemd.
+#
+
+# bats file_tags=ci:parallel
+
+load helpers
+load helpers.network
+load helpers.registry
+load helpers.systemd
+
+UNIT_FILES=()
+
+function start_time() {
+ sleep_to_next_second # Ensure we're on a new second with no previous logging
+ STARTED_TIME=$(date "+%F %R:%S") # Start time for new log time
+}
+
+function setup() {
+ skip_if_remote "quadlet tests are meaningless over remote"
+ skip_if_rootless_cgroupsv1 "Can't use --cgroups=split w/ CGv1 (issue 17456, wontfix)"
+ skip_if_journald_unavailable "Needed for RHEL. FIXME: we might be able to re-enable a subset of tests."
+
+ test -x "$QUADLET" || die "Cannot run quadlet tests without executable \$QUADLET ($QUADLET)"
+
+ start_time
+
+ basic_setup
+}
+
+function teardown() {
+ for UNIT_FILE in ${UNIT_FILES[@]}; do
+ if [[ -e "$UNIT_FILE" ]]; then
+ local service=$(basename "$UNIT_FILE")
+ run systemctl stop "$service"
+ if [ $status -ne 0 ]; then
+ echo "# WARNING: systemctl stop failed in teardown: $output" >&3
+ fi
+ run systemctl reset-failed "$service"
+ rm -f "$UNIT_FILE"
+ fi
+ done
+ systemctl daemon-reload
+
+ basic_teardown
+}
+
+@test "quadlet verb - install, list, rm" {
+ # Create a test quadlet file
+ local quadlet_file=$PODMAN_TMPDIR/alpine-quadlet.container
+ cat > $quadlet_file < $quadlet_dir/alpine1.container < $quadlet_dir/alpine2.container < $quadlet_dir/nginx.container < $test_file
+
+ # Create a quadlet directory for installation
+ local quadlet_dir=$PODMAN_TMPDIR/quadlet-mount-test
+ mkdir -p $quadlet_dir
+
+ # Create quadlet file that mounts the external test.txt file
+ local quadlet_file=$quadlet_dir/mount-test.container
+ cat > $quadlet_file < $quadlet_dir/web-server.container < $quadlet_dir/database.container < $quadlet_dir/cache.container <