This commit is contained in:
flouthoc 2025-06-18 23:05:17 +00:00 committed by GitHub
commit 0fccfb7d8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1899 additions and 456 deletions

View File

@ -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{

View File

@ -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) {

View File

@ -0,0 +1,69 @@
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 one or more Quadlets for the current user. Quadlets may be specified as local files, Web URLs, and OCI artifacts.`
quadletInstallCmd = &cobra.Command{
Use: "install [options] PATH-OR-URL [PATH-OR-URL...]",
Short: "Install one or more quadlets",
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()
}

102
cmd/podman/quadlet/list.go Normal file
View File

@ -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}}\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",
})
rpt := report.New(os.Stdout, cmd.Name())
defer rpt.Flush()
var err error
switch {
case cmd.Flag("format").Changed:
rpt, err = rpt.Parse(report.OriginUser, format)
default:
rpt, err = rpt.Parse(report.OriginPodman, 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
}

View File

@ -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
}

View File

@ -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,
})
}

View File

@ -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()
}

View File

@ -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 {

View File

@ -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:
// <BASE>/linkToDir - real directory to link to
// <BASE>/linkToDir/a - real directory
// <BASE>/linkToDir/b - link to <BASE>/unitDir/b/a should be ignored
// <BASE>/linkToDir/c - link to <BASE>/unitDir should be ignored
// <BASE>/unitDir - start from here
// <BASE>/unitDir/a - real directory
// <BASE>/unitDir/a/a - real directory
// <BASE>/unitDir/a/a/a - real directory
// <BASE>/unitDir/b/a - real directory
// <BASE>/unitDir/b/b - link to <BASE>/unitDir/a/a should be ignored
// <BASE>/unitDir/c - link to <BASE>/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 <BASE>/unitDir
unitsDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "unitsDir", expectedDirs)
// Create <BASE>/unitDir/a
aDirPath, expectedDirs := createDir(unitsDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/a
aaDirPath, expectedDirs := createDir(aDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/a/a
_, expectedDirs = createDir(aaDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/b
_, expectedDirs = createDir(aDirPath, "b", expectedDirs)
// Create <BASE>/unitDir/b
bDirPath, expectedDirs := createDir(unitsDirPath, "b", expectedDirs)
// Create <BASE>/unitDir/b/a
baDirPath, expectedDirs := createDir(bDirPath, "a", expectedDirs)
// Create <BASE>/linkToDir
linkToDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "linkToDir", expectedDirs)
// Create <BASE>/linkToDir/a
_, expectedDirs = createDir(linkToDirPath, "a", expectedDirs)
// Link <BASE>/unitDir/b/b to <BASE>/unitDir/a/a
linkDir(bDirPath, "b", aaDirPath)
// Link <BASE>/linkToDir/b to <BASE>/unitDir/b/a
linkDir(linkToDirPath, "b", baDirPath)
// Link <BASE>/linkToDir/c to <BASE>/unitDir
linkDir(linkToDirPath, "c", unitsDirPath)
// Link <BASE>/unitDir/c to <BASE>/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")
}
}

View File

@ -81,6 +81,8 @@ Commands
:doc:`push <markdown/podman-push.1>` Push an image to a specified destination
:doc:`quadlet <markdown/podman-quadlet.1>` Allows users to manage Quadlets
:doc:`rename <markdown/podman-rename.1>` Rename an existing container
:doc:`restart <markdown/podman-restart.1>` Restart one or more containers

View File

@ -0,0 +1,38 @@
% podman-quadlet-install 1
## NAME
podman\-quadlet\-install - Install one or more quadlets
## SYNOPSIS
**podman quadlet install** [*options*] *path-or-url* [*path-or-url*]...
## DESCRIPTION
Install one or more Quadlets for the current user. Quadlets may be specified as local files, Web URLs.
## 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)**

View File

@ -0,0 +1,54 @@
% 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** |
|-----------------|------------------------------------------------|
| .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)**

View File

@ -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)**

View File

@ -0,0 +1,38 @@
% 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.
## 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)**

View File

@ -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 one or more quadlets |
| 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)**

View File

@ -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. |

View File

@ -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

View File

@ -0,0 +1,59 @@
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
}
// 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
}

View File

@ -0,0 +1,584 @@
//go:build !remote
// +build !remote
package abi
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/exec"
"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"
"golang.org/x/sys/unix"
)
// 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.UnitDirAdmin
if rootless.IsRootless() {
// Install just for the user in question.
quadletRootlessDirs := systemdquadlet.GetUnitDirs(true)
for _, dir := range quadletRootlessDirs {
// Prefer /etc/containers/systemd/users(/$UID)
if strings.HasPrefix(dir, systemdquadlet.UnitDirAdmin) {
// Does it exist and can we write to it? If it doesn't, we cannot use it.
stat, err := os.Stat(dir)
if err != nil || !stat.IsDir() {
continue
}
if unix.Access(dir, unix.W_OK) == nil {
installDir = dir
break
} else {
// If we don't have write permission let's find another path.
continue
}
}
// If we can't use the /etc/ directory, use what is available.
// The permanent directory should always be after the temporary one
// if both exist, so iterate through all directories.
installDir = dir
}
}
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),
}
// Loop over all given URLs
for _, toInstall := range pathsOrURLs {
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)
if quadletExtension == "" {
quadletExtension = ".container"
}
// 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)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
default:
fileInfo, err := os.Stat(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
} else {
if fileInfo.IsDir() {
// If toInstall is a directory, iterate over its files
files, err := os.ReadDir(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
for _, file := range files {
if file.IsDir() {
continue
}
filePath := filepath.Join(toInstall, file.Name())
if !systemdquadlet.IsExtSupported(filePath) {
continue
}
installedPath, err := ic.installQuadlet(ctx, filePath, "", installDir)
if err != nil {
installReport.QuadletErrors[filePath] = err
} else {
installReport.InstalledQuadlets[filePath] = installedPath
}
}
} else {
// If toInstall is a single file, execute the original logic
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir)
if err != nil {
installReport.QuadletErrors[toInstall] = err
} else {
installReport.InstalledQuadlets[toInstall] = installedPath
}
}
}
}
}
// Perform a Quadlet dry-run to validate the syntax of our quadlets
// TODO: This can definitely be improved.
// We can't easily validate single quadlets (you can depend on other quadlets, so validation
// really has to parse everything), but we should be able to output in a machine-readable format
// (JSON, maybe?) so we can easily associated error to quadlet here, and return better
// results.
var validateErr error
quadletArgs := []string{"--dryrun"}
if rootless.IsRootless() {
quadletArgs = append(quadletArgs, "--user")
}
quadletCmd := exec.Command(quadletPath, quadletArgs...)
out, err := quadletCmd.CombinedOutput()
if err != nil {
logrus.Errorf("Error validating Quadlet syntax")
fmt.Fprint(os.Stderr, string(out))
validateErr = errors.New("validating Quadlet syntax failed")
}
// 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, validateErr
}
// 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 string) (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 !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.
return finalPath, 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) {
// 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)
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 {
report := entities.ListQuadlet{
Name: filepath.Base(path),
Path: path,
}
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"
reports = append(reports, &report)
delete(partialReports, unitStatus.Name)
continue
}
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 getQuadletByName(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 := getQuadletByName(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{},
}
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 _, quadlet := range quadlets {
quadletPath, err := getQuadletByName(quadlet)
if options.All {
quadletPath = 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
}
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 {
quadletName := filepath.Base(path)
if slices.Contains(runningQuadlets, quadletName) {
continue
}
if err := os.Remove(path); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
report.Errors[quadletName] = fmt.Errorf("removing quadlet %s: %w", quadletName, err)
continue
}
}
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
}

View File

@ -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
}

24
pkg/logiface/logiface.go Normal file
View File

@ -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...)
}
}

View File

@ -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)?$`)
@ -1449,6 +1437,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, "")
}

View File

@ -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,
}
)

View File

@ -0,0 +1,233 @@
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 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)
}

View File

@ -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:
// <BASE>/linkToDir - real directory to link to
// <BASE>/linkToDir/a - real directory
// <BASE>/linkToDir/b - link to <BASE>/unitDir/b/a should be ignored
// <BASE>/linkToDir/c - link to <BASE>/unitDir should be ignored
// <BASE>/unitDir - start from here
// <BASE>/unitDir/a - real directory
// <BASE>/unitDir/a/a - real directory
// <BASE>/unitDir/a/a/a - real directory
// <BASE>/unitDir/b/a - real directory
// <BASE>/unitDir/b/b - link to <BASE>/unitDir/a/a should be ignored
// <BASE>/unitDir/c - link to <BASE>/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 <BASE>/unitDir
unitsDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "unitsDir", expectedDirs)
// Create <BASE>/unitDir/a
aDirPath, expectedDirs := createDir(unitsDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/a
aaDirPath, expectedDirs := createDir(aDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/a/a
_, expectedDirs = createDir(aaDirPath, "a", expectedDirs)
// Create <BASE>/unitDir/a/b
_, expectedDirs = createDir(aDirPath, "b", expectedDirs)
// Create <BASE>/unitDir/b
bDirPath, expectedDirs := createDir(unitsDirPath, "b", expectedDirs)
// Create <BASE>/unitDir/b/a
baDirPath, expectedDirs := createDir(bDirPath, "a", expectedDirs)
// Create <BASE>/linkToDir
linkToDirPath, expectedDirs := createDir(symLinkRecursiveTestBaseDir, "linkToDir", expectedDirs)
// Create <BASE>/linkToDir/a
_, expectedDirs = createDir(linkToDirPath, "a", expectedDirs)
// Link <BASE>/unitDir/b/b to <BASE>/unitDir/a/a
linkDir(bDirPath, "b", aaDirPath)
// Link <BASE>/linkToDir/b to <BASE>/unitDir/b/a
linkDir(linkToDirPath, "b", baDirPath)
// Link <BASE>/linkToDir/c to <BASE>/unitDir
linkDir(linkToDirPath, "c", unitsDirPath)
// Link <BASE>/unitDir/c to <BASE>/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")
}
}

View File

@ -1865,4 +1865,164 @@ EOF
run_podman rmi -i $image_tag
}
@test "quadlet verb - install, list, rm" {
# Create a test quadlet file
local quadlet_file=$PODMAN_TMPDIR/alpine-quadlet.container
cat > $quadlet_file <<EOF
[Container]
Image=$IMAGE
Exec=sh -c "echo STARTED CONTAINER; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
Notify=yes
LogDriver=passthrough
EOF
# Clean all quadlets
run_podman quadlet rm --all -f
assert $status -eq 0 "all quadlets should be cleaned"
# Test quadlet install
run_podman quadlet install $quadlet_file
assert $status -eq 0 "quadlet install should succeed"
# Test quadlet list
run_podman quadlet list
assert $status -eq 0 "quadlet list should succeed"
assert "$output" =~ "alpine-quadlet.container" "list should contain alpine-quadlet.container"
# Test quadlet list with filter
run_podman quadlet list --filter name=something*
assert $status -eq 0 "quadlet list with filter should succeed"
assert "$output" !~ "alpine-quadlet.container" "filtered list should not contain alpine-quadlet.container"
# Test quadlet list with matching filter
run_podman quadlet list --filter name=alpine*
assert $status -eq 0 "quadlet list with matching filter should succeed"
assert "$output" =~ "alpine-quadlet.container" "matching filter should contain alpine-quadlet.container"
# Test quadlet print
run_podman quadlet print alpine-quadlet.container
assert $status -eq 0 "quadlet print should succeed"
assert "$output" =~ "\[Container\]" "print should show container section"
assert "$output" =~ "Image=$IMAGE" "print should show correct image"
# Test quadlet rm
run_podman quadlet rm alpine-quadlet.container
assert $status -eq 0 "quadlet rm should succeed"
# Verify removal
run_podman quadlet list
assert $status -eq 0 "quadlet list after removal should succeed"
assert "$output" !~ "alpine-quadlet.container" "list should not contain removed container"
}
# bats test_tags=distro-integration
@test "quadlet verb - install multiple files from directory" {
# Create a directory for multiple quadlet files
local quadlet_dir=$PODMAN_TMPDIR/quadlet-multi
mkdir -p $quadlet_dir
# Create multiple quadlet files with different configurations
cat > $quadlet_dir/alpine1.container <<EOF
[Container]
Image=$IMAGE
Exec=sh -c "echo STARTED CONTAINER 1; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
Notify=yes
LogDriver=passthrough
EOF
cat > $quadlet_dir/alpine2.container <<EOF
[Container]
Image=$IMAGE
Exec=sh -c "echo STARTED CONTAINER 2; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
Notify=yes
LogDriver=passthrough
EOF
cat > $quadlet_dir/nginx.container <<EOF
[Container]
Image=quay.io/libpod/nginx:latest
Exec=sh -c "echo STARTED NGINX; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
Notify=yes
LogDriver=passthrough
EOF
# Clean all quadlets
run_podman quadlet rm --all -f
assert $status -eq 0 "all quadlets should be cleaned"
# Test quadlet install with directory
run_podman quadlet install $quadlet_dir
assert $status -eq 0 "quadlet install with directory should succeed"
# Test quadlet list to verify all containers were installed
run_podman quadlet list
assert $status -eq 0 "quadlet list should succeed"
assert "$output" =~ "alpine1.container" "list should contain alpine1.container"
assert "$output" =~ "alpine2.container" "list should contain alpine2.container"
assert "$output" =~ "nginx.container" "list should contain nginx.container"
# Test quadlet list with filter for alpine containers
run_podman quadlet list --filter name=alpine*
assert $status -eq 0 "quadlet list with filter should succeed"
assert "$output" =~ "alpine1.container" "filtered list should contain alpine1.container"
assert "$output" =~ "alpine2.container" "filtered list should contain alpine2.container"
assert "$output" !~ "nginx.container" "filtered list should not contain nginx.container"
# Test quadlet print for each container
run_podman quadlet print alpine1.container
assert $status -eq 0 "quadlet print should succeed for alpine1"
assert "$output" =~ "Image=$IMAGE" "print should show correct image for alpine1"
run_podman quadlet print alpine2.container
assert $status -eq 0 "quadlet print should succeed for alpine2"
assert "$output" =~ "Image=$IMAGE" "print should show correct image for alpine2"
run_podman quadlet print nginx.container
assert $status -eq 0 "quadlet print should succeed for nginx"
assert "$output" =~ "Image=quay.io/libpod/nginx:latest" "print should show correct image for nginx"
# Test quadlet rm for all containers
run_podman quadlet rm alpine1.container alpine2.container nginx.container
assert $status -eq 0 "quadlet rm should succeed for all containers"
# Verify all containers were removed
run_podman quadlet list
assert $status -eq 0 "quadlet list after removal should succeed"
assert "$output" !~ "alpine1.container" "list should not contain removed container alpine1"
assert "$output" !~ "alpine2.container" "list should not contain removed container alpine2"
assert "$output" !~ "nginx.container" "list should not contain removed container nginx"
}
# bats test_tags=distro-integration
@test "quadlet verb - install from URL" {
# Clean all quadlets
run_podman quadlet rm --all -f
assert $status -eq 0 "all quadlets should be cleaned"
# Test quadlet install from URL and capture the output
run_podman quadlet install https://raw.githubusercontent.com/containers/podman/main/test/e2e/quadlet/basic.container
assert $status -eq 0 "quadlet install from URL should succeed"
# Extract just the basename from the full path
quadlet_name=$(basename "$output")
# Test quadlet list to verify the container was installed
run_podman quadlet list
assert $status -eq 0 "quadlet list should succeed"
assert "$output" =~ "$quadlet_name" "list should contain $quadlet_name"
# Test quadlet print to verify the configuration
run_podman quadlet print "$quadlet_name"
assert $status -eq 0 "quadlet print should succeed"
assert "$output" =~ "\[Container\]" "print should show container section"
assert "$output" =~ "Image=" "print should show image configuration"
# Test quadlet rm
run_podman quadlet rm "$quadlet_name"
assert $status -eq 0 "quadlet rm should succeed"
# Verify removal
run_podman quadlet list
assert $status -eq 0 "quadlet list after removal should succeed"
assert "$output" !~ "$quadlet_name" "list should not contain removed container"
}
# vim: filetype=sh