diff --git a/Makefile b/Makefile index 10944dcc96..194d9d8604 100644 --- a/Makefile +++ b/Makefile @@ -30,6 +30,7 @@ HEAD ?= HEAD PROJECT := github.com/containers/podman GIT_BASE_BRANCH ?= origin/main LIBPOD_INSTANCE := libpod_dev +QUADLET_USER ?= quadlet PREFIX ?= /usr/local BINDIR ?= ${PREFIX}/bin LIBEXECDIR ?= ${PREFIX}/libexec @@ -42,6 +43,8 @@ USERTMPFILESDIR ?= ${PREFIX}/share/user-tmpfiles.d MODULESLOADDIR ?= ${PREFIX}/lib/modules-load.d SYSTEMDDIR ?= ${PREFIX}/lib/systemd/system USERSYSTEMDDIR ?= ${PREFIX}/lib/systemd/user +SYSTEMDGENERATORSDIR ?= ${PREFIX}/lib/systemd/system-generators +USERSYSTEMDGENERATORSDIR ?= ${PREFIX}/lib/systemd/user-generators REMOTETAGS ?= remote exclude_graphdriver_btrfs btrfs_noversion exclude_graphdriver_devicemapper containers_image_openpgp BUILDTAGS ?= \ $(shell hack/apparmor_tag.sh) \ @@ -109,6 +112,7 @@ LDFLAGS_PODMAN ?= \ -X $(LIBPOD)/config._installPrefix=$(PREFIX) \ -X $(LIBPOD)/config._etcDir=$(ETCDIR) \ -X github.com/containers/common/pkg/config.additionalHelperBinariesDir=$(HELPER_BINARIES_DIR)\ + -X $(PROJECT)/v4/pkg/quadlet.QuadletUserName=$(QUADLET_USER) \ $(EXTRA_LDFLAGS) LDFLAGS_PODMAN_STATIC ?= \ $(LDFLAGS_PODMAN) \ @@ -206,7 +210,7 @@ all: binaries docs ifeq ($(shell uname -s),FreeBSD) binaries: podman podman-remote ## Build podman and podman-remote binaries else -binaries: podman podman-remote rootlessport ## Build podman, podman-remote and rootlessport binaries +binaries: podman podman-remote rootlessport quadlet ## Build podman, podman-remote and rootlessport binaries quadlet endif # Extract text following double-# for targets, as their description for @@ -337,6 +341,16 @@ podman: bin/podman .PHONY: podman-remote podman-remote: $(SRCBINDIR)/podman$(BINSFX) +$(SRCBINDIR)/quadlet: $(SOURCES) go.mod go.sum + $(GOCMD) build \ + $(BUILDFLAGS) \ + $(GO_LDFLAGS) '$(LDFLAGS_PODMAN)' \ + -tags "${BUILDTAGS}" \ + -o $@ ./cmd/quadlet + +.PHONY: quadlet +quadlet: bin/quadlet + PHONY: podman-remote-static podman-remote-static: $(SRCBINDIR)/podman-remote-static @@ -771,6 +785,11 @@ ifneq ($(shell uname -s),FreeBSD) install ${SELINUXOPT} -m 755 bin/rootlessport $(DESTDIR)$(LIBEXECPODMAN)/rootlessport endif test -z "${SELINUXOPT}" || chcon --verbose --reference=$(DESTDIR)$(LIBEXECPODMAN)/rootlessport bin/rootlessport + install ${SELINUXOPT} -m 755 bin/quadlet $(DESTDIR)$(LIBEXECPODMAN)/quadlet + install ${SELINUXOPT} -d -m 755 $(DESTDIR)$(SYSTEMDGENERATORSDIR) + ln -sfr $(DESTDIR)$(LIBEXECPODMAN)/quadlet $(DESTDIR)$(SYSTEMDGENERATORSDIR)/podman-system-generator + install ${SELINUXOPT} -d -m 755 $(DESTDIR)$(USERSYSTEMDGENERATORSDIR) + ln -sfr $(DESTDIR)$(LIBEXECPODMAN)/quadlet $(DESTDIR)$(USERSYSTEMDGENERATORSDIR)/podman-user-generator install ${SELINUXOPT} -m 755 -d ${DESTDIR}${TMPFILESDIR} install ${SELINUXOPT} -m 644 contrib/tmpfile/podman.conf ${DESTDIR}${TMPFILESDIR}/podman.conf diff --git a/cmd/quadlet/main.go b/cmd/quadlet/main.go new file mode 100644 index 0000000000..1e524b87a6 --- /dev/null +++ b/cmd/quadlet/main.go @@ -0,0 +1,273 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/containers/podman/v4/pkg/quadlet" + "github.com/containers/podman/v4/pkg/systemdparser" +) + +// This commandline app is the systemd generator (system and user, +// decided by the name of the binary). + +// Generators run at very early startup, so must work in a very +// limited environment (e.g. no /var, /home, or syslog). See: +// https://www.freedesktop.org/software/systemd/man/systemd.generator.html#Notes%20about%20writing%20generators +// for more details. + +var ( + verboseFlag bool // True if -v passed + isUser bool // True if run as quadlet-user-generator executable +) + +var ( + // data saved between logToKmsg calls + noKmsg = false + kmsgFile *os.File +) + +// We log directly to /dev/kmsg, because that is the only way to get information out +// of the generator into the system logs. +func logToKmsg(s string) bool { + if noKmsg { + return false + } + + if kmsgFile == nil { + f, err := os.OpenFile("/dev/kmsg", os.O_WRONLY, 0644) + if err != nil { + noKmsg = true + return false + } + kmsgFile = f + } + + if _, err := kmsgFile.Write([]byte(s)); err != nil { + kmsgFile.Close() + kmsgFile = nil + return false + } + + return true +} + +func Logf(format string, a ...interface{}) { + s := fmt.Sprintf(format, a...) + line := fmt.Sprintf("quadlet-generator[%d]: %s", os.Getpid(), s) + + if !logToKmsg(line) { + // If we can't log, print to stderr + fmt.Fprintf(os.Stderr, "%s\n", line) + os.Stderr.Sync() + } +} + +var debugEnabled = false + +func enableDebug() { + debugEnabled = true +} + +func Debugf(format string, a ...interface{}) { + if debugEnabled { + Logf(format, a...) + } +} + +// 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 live in $XDG_CONFIG_HOME/containers/systemd +func getUnitDirs(user bool) []string { + // Allow overdiding source dir, this is mainly for the CI tests + unitDirsEnv := os.Getenv("QUADLET_UNIT_DIRS") + if len(unitDirsEnv) > 0 { + return strings.Split(unitDirsEnv, ":") + } + + dirs := make([]string, 0) + if user { + if configDir, err := os.UserConfigDir(); err == nil { + dirs = append(dirs, path.Join(configDir, "containers/systemd")) + } + } else { + dirs = append(dirs, quadlet.UnitDirAdmin) + dirs = append(dirs, quadlet.UnitDirDistro) + } + return dirs +} + +func loadUnitsFromDir(sourcePath string, units map[string]*systemdparser.UnitFile) { + files, err := os.ReadDir(sourcePath) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + Logf("Can't read \"%s\": %s", sourcePath, err) + } + return + } + + for _, file := range files { + name := file.Name() + if units[name] == nil && + (strings.HasSuffix(name, ".container") || + strings.HasSuffix(name, ".volume")) { + path := path.Join(sourcePath, name) + + Debugf("Loading source unit file %s", path) + + if f, err := systemdparser.ParseUnitFile(path); err != nil { + Logf("Error loading '%s', ignoring: %s", path, err) + } else { + units[name] = f + } + } + } +} + +func generateServiceFile(service *systemdparser.UnitFile) error { + Debugf("writing '%s'", service.Path) + + service.PrependComment("", + "Automatically generated by quadlet-generator", + "") + + f, err := os.Create(service.Path) + if err != nil { + return err + } + + defer f.Close() + + err = service.Write(f) + if err != nil { + return err + } + + err = f.Sync() + if err != nil { + return err + } + + return nil +} + +// This parses the `Install` group of the unit file and creates the required +// symlinks to get systemd to start the newly generated file as needed. +// In a traditional setup this is done by "systemctl enable", but that doesn't +// work for auto-generated files like these. +func enableServiceFile(outputPath string, service *systemdparser.UnitFile) { + symlinks := make([]string, 0) + + aliases := service.LookupAllStrv(quadlet.InstallGroup, "Alias") + for _, alias := range aliases { + symlinks = append(symlinks, filepath.Clean(alias)) + } + + wantedBy := service.LookupAllStrv(quadlet.InstallGroup, "WantedBy") + for _, wantedByUnit := range wantedBy { + // Only allow filenames, not paths + if !strings.Contains(wantedByUnit, "/") { + symlinks = append(symlinks, fmt.Sprintf("%s.wants/%s", wantedByUnit, service.Filename)) + } + } + + requiredBy := service.LookupAllStrv(quadlet.InstallGroup, "RequiredBy") + for _, requiredByUnit := range requiredBy { + // Only allow filenames, not paths + if !strings.Contains(requiredByUnit, "/") { + symlinks = append(symlinks, fmt.Sprintf("%s.requires/%s", requiredByUnit, service.Filename)) + } + } + + for _, symlinkRel := range symlinks { + target, err := filepath.Rel(path.Dir(symlinkRel), service.Filename) + if err != nil { + Logf("Can't create symlink %s: %s", symlinkRel, err) + continue + } + symlinkPath := path.Join(outputPath, symlinkRel) + + symlinkDir := path.Dir(symlinkPath) + err = os.MkdirAll(symlinkDir, os.ModePerm) + if err != nil { + Logf("Can't create dir %s: %s", symlinkDir, err) + continue + } + + Debugf("Creating symlink %s -> %s", symlinkPath, target) + _ = os.Remove(symlinkPath) // overwrite existing symlinks + err = os.Symlink(target, symlinkPath) + if err != nil { + Logf("Failed creating symlink %s: %s", symlinkPath, err) + } + } +} + +func main() { + prgname := path.Base(os.Args[0]) + isUser = strings.Contains(prgname, "user") + + flag.Parse() + + if verboseFlag { + enableDebug() + } + + if flag.NArg() < 1 { + Logf("Missing output directory argument") + os.Exit(1) + } + + outputPath := flag.Arg(0) + + Debugf("Starting quadlet-generator, output to: %s", outputPath) + + sourcePaths := getUnitDirs(isUser) + + units := make(map[string]*systemdparser.UnitFile) + for _, d := range sourcePaths { + loadUnitsFromDir(d, units) + } + + err := os.MkdirAll(outputPath, os.ModePerm) + if err != nil { + Logf("Can't create dir %s: %s", outputPath, err) + os.Exit(1) + } + + for name, unit := range units { + var service *systemdparser.UnitFile + var err error + + switch { + case strings.HasSuffix(name, ".container"): + service, err = quadlet.ConvertContainer(unit, isUser) + case strings.HasSuffix(name, ".volume"): + service, err = quadlet.ConvertVolume(unit, name) + default: + Logf("Unsupported file type '%s'", name) + continue + } + + if err != nil { + Logf("Error converting '%s', ignoring: %s", name, err) + } else { + service.Path = path.Join(outputPath, service.Filename) + + if err := generateServiceFile(service); err != nil { + Logf("Error writing '%s'o: %s", service.Path, err) + } + enableServiceFile(outputPath, service) + } + } +} + +func init() { + flag.BoolVar(&verboseFlag, "v", false, "Print debug information") +} diff --git a/go.mod b/go.mod index d1077fd3ad..be6818aed0 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/json-iterator/go v1.1.12 github.com/mattn/go-isatty v0.0.16 + github.com/mattn/go-shellwords v1.0.12 github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 github.com/nxadm/tail v1.4.8 github.com/onsi/ginkgo v1.16.5 @@ -105,7 +106,6 @@ require ( github.com/letsencrypt/boulder v0.0.0-20220723181115-27de4befb95e // indirect github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/mattn/go-shellwords v1.0.12 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mistifyio/go-zfs/v3 v3.0.0 // indirect github.com/moby/sys/mount v0.3.3 // indirect diff --git a/pkg/quadlet/podmancmdline.go b/pkg/quadlet/podmancmdline.go new file mode 100644 index 0000000000..adec5b2c23 --- /dev/null +++ b/pkg/quadlet/podmancmdline.go @@ -0,0 +1,60 @@ +package quadlet + +import ( + "fmt" + "sort" +) + +/* This is a helper for constructing podman commandlines */ +type PodmanCmdline struct { + Args []string +} + +func (c *PodmanCmdline) add(args ...string) { + c.Args = append(c.Args, args...) +} + +func (c *PodmanCmdline) addf(format string, a ...interface{}) { + c.add(fmt.Sprintf(format, a...)) +} + +func (c *PodmanCmdline) addKeys(arg string, keys map[string]string) { + ks := make([]string, 0, len(keys)) + for k := range keys { + ks = append(ks, k) + } + sort.Strings(ks) + + for _, k := range ks { + c.add(arg, fmt.Sprintf("%s=%s", k, keys[k])) + } +} + +func (c *PodmanCmdline) addEnv(env map[string]string) { + c.addKeys("--env", env) +} + +func (c *PodmanCmdline) addLabels(labels map[string]string) { + c.addKeys("--label", labels) +} + +func (c *PodmanCmdline) addAnnotations(annotations map[string]string) { + c.addKeys("--annotation", annotations) +} + +func (c *PodmanCmdline) addIDMap(argPrefix string, containerIDStart, hostIDStart, numIDs uint32) { + if numIDs != 0 { + c.add(argPrefix) + c.addf("%d:%d:%d", containerIDStart, hostIDStart, numIDs) + } +} + +func NewPodmanCmdline(args ...string) *PodmanCmdline { + c := &PodmanCmdline{ + Args: make([]string, 0), + } + + c.add("/usr/bin/podman") + c.add(args...) + return c +} diff --git a/pkg/quadlet/quadlet.go b/pkg/quadlet/quadlet.go new file mode 100644 index 0000000000..78c672a7e9 --- /dev/null +++ b/pkg/quadlet/quadlet.go @@ -0,0 +1,705 @@ +package quadlet + +import ( + "fmt" + "math" + "os" + "regexp" + "strings" + "unicode" + + "github.com/containers/podman/v4/pkg/systemdparser" +) + +// Overwritten at build time: +var ( + QuadletUserName = "quadlet" // Name of user used to look up subuid/subgid for remap uids +) + +const ( + // Directory for global Quadlet files (sysadmin owned) + UnitDirAdmin = "/etc/containers/systemd" + // Directory for global Quadlet files (distro owned) + UnitDirDistro = "/usr/share/containers/systemd" + + // Names of commonly used systemd/quadlet group names + UnitGroup = "Unit" + InstallGroup = "Install" + ServiceGroup = "Service" + ContainerGroup = "Container" + XContainerGroup = "X-Container" + VolumeGroup = "Volume" + XVolumeGroup = "X-Volume" + + // Fallbacks uid/gid ranges if the above username doesn't exist or has no subuids + FallbackUIDStart = 1879048192 + FallbackUIDLength = 165536 + FallbackGIDStart = 1879048192 + FallbackGIDLength = 165536 +) + +var validPortRange = regexp.MustCompile(`\d+(-\d+)?(/udp|/tcp)?$`) + +// All the supported quadlet keys +const ( + KeyContainerName = "ContainerName" + KeyImage = "Image" + KeyEnvironment = "Environment" + KeyExec = "Exec" + KeyNoNewPrivileges = "NoNewPrivileges" + KeyDropCapability = "DropCapability" + KeyAddCapability = "AddCapability" + KeyReadOnly = "ReadOnly" + KeyRemapUsers = "RemapUsers" + KeyRemapUIDStart = "RemapUidStart" + KeyRemapGIDStart = "RemapGidStart" + KeyRemapUIDRanges = "RemapUidRanges" + KeyRemapGIDRanges = "RemapGidRanges" + KeyNotify = "Notify" + KeySocketActivated = "SocketActivated" + KeyExposeHostPort = "ExposeHostPort" + KeyPublishPort = "PublishPort" + KeyKeepID = "KeepId" + KeyUser = "User" + KeyGroup = "Group" + KeyHostUser = "HostUser" + KeyHostGroup = "HostGroup" + KeyVolume = "Volume" + KeyPodmanArgs = "PodmanArgs" + KeyLabel = "Label" + KeyAnnotation = "Annotation" + KeyRunInit = "RunInit" + KeyVolatileTmp = "VolatileTmp" + KeyTimezone = "Timezone" +) + +// Supported keys in "Container" group +var supportedContainerKeys = map[string]bool{ + KeyContainerName: true, + KeyImage: true, + KeyEnvironment: true, + KeyExec: true, + KeyNoNewPrivileges: true, + KeyDropCapability: true, + KeyAddCapability: true, + KeyReadOnly: true, + KeyRemapUsers: true, + KeyRemapUIDStart: true, + KeyRemapGIDStart: true, + KeyRemapUIDRanges: true, + KeyRemapGIDRanges: true, + KeyNotify: true, + KeySocketActivated: true, + KeyExposeHostPort: true, + KeyPublishPort: true, + KeyKeepID: true, + KeyUser: true, + KeyGroup: true, + KeyHostUser: true, + KeyHostGroup: true, + KeyVolume: true, + KeyPodmanArgs: true, + KeyLabel: true, + KeyAnnotation: true, + KeyRunInit: true, + KeyVolatileTmp: true, + KeyTimezone: true, +} + +// Supported keys in "Volume" group +var supportedVolumeKeys = map[string]bool{ + KeyUser: true, + KeyGroup: true, + KeyLabel: true, +} + +func replaceExtension(name string, extension string, extraPrefix string, extraSuffix string) string { + baseName := name + + dot := strings.LastIndexByte(name, '.') + if dot > 0 { + baseName = name[:dot] + } + + return extraPrefix + baseName + extraSuffix + extension +} + +var defaultRemapUIDs, defaultRemapGIDs *Ranges + +func getDefaultRemapUids() *Ranges { + if defaultRemapUIDs == nil { + defaultRemapUIDs = lookupHostSubuid(QuadletUserName) + if defaultRemapUIDs == nil { + defaultRemapUIDs = + NewRanges(FallbackUIDStart, FallbackUIDLength) + } + } + return defaultRemapUIDs +} + +func getDefaultRemapGids() *Ranges { + if defaultRemapGIDs == nil { + defaultRemapGIDs = lookupHostSubgid(QuadletUserName) + if defaultRemapGIDs == nil { + defaultRemapGIDs = + NewRanges(FallbackGIDStart, FallbackGIDLength) + } + } + return defaultRemapGIDs +} + +func isPortRange(port string) bool { + return validPortRange.MatchString(port) +} + +func checkForUnknownKeys(unit *systemdparser.UnitFile, groupName string, supportedKeys map[string]bool) error { + keys := unit.ListKeys(groupName) + for _, key := range keys { + if !supportedKeys[key] { + return fmt.Errorf("unsupported key '%s' in group '%s' in %s", key, groupName, unit.Path) + } + } + return nil +} + +func lookupRanges(unit *systemdparser.UnitFile, groupName string, key string, nameLookup func(string) *Ranges, defaultValue *Ranges) *Ranges { + v, ok := unit.Lookup(groupName, key) + if !ok { + if defaultValue != nil { + return defaultValue.Copy() + } + + return NewRangesEmpty() + } + + if len(v) == 0 { + return NewRangesEmpty() + } + + if !unicode.IsDigit(rune(v[0])) { + if nameLookup != nil { + r := nameLookup(v) + if r != nil { + return r + } + } + return NewRangesEmpty() + } + + return ParseRanges(v) +} + +func splitPorts(ports string) []string { + parts := make([]string, 0) + + // IP address could have colons in it. For example: "[::]:8080:80/tcp, so we split carefully + start := 0 + end := 0 + for end < len(ports) { + switch ports[end] { + case '[': + end++ + for end < len(ports) && ports[end] != ']' { + end++ + } + if end < len(ports) { + end++ // Skip ] + } + case ':': + parts = append(parts, ports[start:end]) + end++ + start = end + default: + end++ + } + } + + parts = append(parts, ports[start:end]) + return parts +} + +func addIDMaps(podman *PodmanCmdline, argPrefix string, containerID, hostID, remapStartID uint32, availableHostIDs *Ranges) { + if availableHostIDs == nil { + // Map everything by default + availableHostIDs = NewRangesEmpty() + } + + // Map the first ids up to remapStartID to the host equivalent + unmappedIds := NewRanges(0, remapStartID) + + // The rest we want to map to availableHostIDs. Note that this + // overlaps unmappedIds, because below we may remove ranges from + // unmapped ids and we want to backfill those. + mappedIds := NewRanges(0, math.MaxUint32) + + // Always map specified uid to specified host_uid + podman.addIDMap(argPrefix, containerID, hostID, 1) + + // We no longer want to map this container id as its already mapped + mappedIds.Remove(containerID, 1) + unmappedIds.Remove(containerID, 1) + + // But also, we don't want to use the *host* id again, as we can only map it once + unmappedIds.Remove(hostID, 1) + availableHostIDs.Remove(hostID, 1) + + // Map unmapped ids to equivalent host range, and remove from mappedIds to avoid double-mapping + for _, r := range unmappedIds.Ranges { + start := r.Start + length := r.Length + + podman.addIDMap(argPrefix, start, start, length) + mappedIds.Remove(start, length) + availableHostIDs.Remove(start, length) + } + + for cIdx := 0; cIdx < len(mappedIds.Ranges) && len(availableHostIDs.Ranges) > 0; cIdx++ { + cRange := &mappedIds.Ranges[cIdx] + cStart := cRange.Start + cLength := cRange.Length + + for cLength > 0 && len(availableHostIDs.Ranges) > 0 { + hRange := &availableHostIDs.Ranges[0] + hStart := hRange.Start + hLength := hRange.Length + + nextLength := minUint32(hLength, cLength) + + podman.addIDMap(argPrefix, cStart, hStart, nextLength) + availableHostIDs.Remove(hStart, nextLength) + cStart += nextLength + cLength -= nextLength + } + } +} + +// Convert a quadlet container file (unit file with a Container group) to a systemd +// service file (unit file with Service group) based on the options in the +// Container group. +// The original Container group is kept around as X-Container. +func ConvertContainer(container *systemdparser.UnitFile, isUser bool) (*systemdparser.UnitFile, error) { + service := container.Dup() + service.Filename = replaceExtension(container.Filename, ".service", "", "") + + if container.Path != "" { + service.Add(UnitGroup, "SourcePath", container.Path) + } + + if err := checkForUnknownKeys(container, ContainerGroup, supportedContainerKeys); err != nil { + return nil, err + } + + // Rename old Container group to x-Container so that systemd ignores it + service.RenameGroup(ContainerGroup, XContainerGroup) + + image, ok := container.Lookup(ContainerGroup, KeyImage) + if !ok || len(image) == 0 { + return nil, fmt.Errorf("no Image key specified") + } + + containerName, ok := container.Lookup(ContainerGroup, KeyContainerName) + if !ok || len(containerName) == 0 { + // By default, We want to name the container by the service name + containerName = "systemd-%N" + } + + // Set PODMAN_SYSTEMD_UNIT so that podman auto-update can restart the service. + service.Add(ServiceGroup, "Environment", "PODMAN_SYSTEMD_UNIT=%n") + + // Only allow mixed or control-group, as nothing else works well + killMode, ok := service.Lookup(ServiceGroup, "KillMode") + if !ok || !(killMode == "mixed" || killMode == "control-group") { + if ok { + return nil, fmt.Errorf("invalid KillMode '%s'", killMode) + } + + // We default to mixed instead of control-group, because it lets conmon do its thing + service.Set(ServiceGroup, "KillMode", "mixed") + } + + // Read env early so we can override it below + podmanEnv := container.LookupAllKeyVal(ContainerGroup, KeyEnvironment) + + // Need the containers filesystem mounted to start podman + service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") + + // Remove any leftover cid file before starting, just to be sure. + // We remove any actual pre-existing container by name with --replace=true. + // But --cidfile will fail if the target exists. + service.Add(ServiceGroup, "ExecStartPre", "-rm -f %t/%N.cid") + + // If the conman exited uncleanly it may not have removed the container, so force it, + // -i makes it ignore non-existing files. + service.Add(ServiceGroup, "ExecStopPost", "-/usr/bin/podman rm -f -i --cidfile=%t/%N.cid") + + // Remove the cid file, to avoid confusion as the container is no longer running. + service.Add(ServiceGroup, "ExecStopPost", "-rm -f %t/%N.cid") + + podman := NewPodmanCmdline("run") + + podman.addf("--name=%s", containerName) + + podman.add( + // We store the container id so we can clean it up in case of failure + "--cidfile=%t/%N.cid", + + // And replace any previous container with the same name, not fail + "--replace", + + // On clean shutdown, remove container + "--rm", + + // Detach from container, we don't need the podman process to hang around + "-d", + + // But we still want output to the journal, so use the log driver. + // TODO: Once available we want to use the passthrough log-driver instead. + "--log-driver", "journald", + + // Never try to pull the image during service start + "--pull=never") + + // We use crun as the runtime and delegated groups to it + service.Add(ServiceGroup, "Delegate", "yes") + podman.add( + "--runtime", "/usr/bin/crun", + "--cgroups=split") + + timezone, ok := container.Lookup(ContainerGroup, KeyTimezone) + if ok && len(timezone) > 0 { + podman.addf("--tz=%s", timezone) + } + + // Run with a pid1 init to reap zombies by default (as most apps don't do that) + runInit := container.LookupBoolean(ContainerGroup, KeyRunInit, true) + if runInit { + podman.add("--init") + } + + // By default we handle startup notification with conmon, but allow passing it to the container with Notify=yes + notify := container.LookupBoolean(ContainerGroup, KeyNotify, false) + if notify { + podman.add("--sdnotify=container") + } else { + podman.add("--sdnotify=conmon") + } + service.Setv(ServiceGroup, + "Type", "notify", + "NotifyAccess", "all") + + if !container.HasKey(ServiceGroup, "SyslogIdentifier") { + service.Set(ServiceGroup, "SyslogIdentifier", "%N") + } + + // Default to no higher level privileges or caps + noNewPrivileges := container.LookupBoolean(ContainerGroup, KeyNoNewPrivileges, true) + if noNewPrivileges { + podman.add("--security-opt=no-new-privileges") + } + + dropCaps := []string{"all"} // Default + if container.HasKey(ContainerGroup, KeyDropCapability) { + dropCaps = container.LookupAll(ContainerGroup, KeyDropCapability) + } + + for _, caps := range dropCaps { + podman.addf("--cap-drop=%s", strings.ToLower(caps)) + } + + // But allow overrides with AddCapability + addCaps := container.LookupAll(ContainerGroup, KeyAddCapability) + for _, caps := range addCaps { + podman.addf("--cap-add=%s", strings.ToLower(caps)) + } + + readOnly := container.LookupBoolean(ContainerGroup, KeyReadOnly, false) + if readOnly { + podman.add("--read-only") + } + + // We want /tmp to be a tmpfs, like on rhel host + volatileTmp := container.LookupBoolean(ContainerGroup, KeyVolatileTmp, true) + if volatileTmp { + /* Read only mode already has a tmpfs by default */ + if !readOnly { + podman.add("--tmpfs", "/tmp:rw,size=512M,mode=1777") + } + } else if readOnly { + /* !volatileTmp, disable the default tmpfs from --read-only */ + podman.add("--read-only-tmpfs=false") + } + + socketActivated := container.LookupBoolean(ContainerGroup, KeySocketActivated, false) + if socketActivated { + // TODO: This will not be needed with later podman versions that support activation directly: + // https://github.com/containers/podman/pull/11316 + podman.add("--preserve-fds=1") + podmanEnv["LISTEN_FDS"] = "1" + + // TODO: This will not be 2 when catatonit forwards fds: + // https://github.com/openSUSE/catatonit/pull/15 + podmanEnv["LISTEN_PID"] = "2" + } + + defaultContainerUID := uint32(0) + defaultContainerGID := uint32(0) + + keepID := container.LookupBoolean(ContainerGroup, KeyKeepID, false) + if keepID { + if isUser { + defaultContainerUID = uint32(os.Getuid()) + defaultContainerGID = uint32(os.Getgid()) + podman.add("--userns", "keep-id") + } else { + return nil, fmt.Errorf("key 'KeepId' in '%s' unsupported for system units", container.Path) + } + } + + uid := container.LookupUint32(ContainerGroup, KeyUser, defaultContainerUID) + gid := container.LookupUint32(ContainerGroup, KeyGroup, defaultContainerGID) + + hostUID, err := container.LookupUID(ContainerGroup, KeyHostUser, uid) + if err != nil { + return nil, fmt.Errorf("key 'HostUser' invalid: %s", err) + } + + hostGID, err := container.LookupGID(ContainerGroup, KeyHostGroup, gid) + if err != nil { + return nil, fmt.Errorf("key 'HostGroup' invalid: %s", err) + } + + if uid != defaultContainerUID || gid != defaultContainerGID { + podman.add("--user") + if gid == defaultContainerGID { + podman.addf("%d", uid) + } else { + podman.addf("%d:%d", uid, gid) + } + } + + var remapUsers bool + if isUser { + remapUsers = false + } else { + remapUsers = container.LookupBoolean(ContainerGroup, KeyRemapUsers, false) + } + + if !remapUsers { + // No remapping of users, although we still need maps if the + // main user/group is remapped, even if most ids map one-to-one. + if uid != hostUID { + addIDMaps(podman, "--uidmap", uid, hostUID, math.MaxUint32, nil) + } + if gid != hostGID { + addIDMaps(podman, "--gidmap", gid, hostGID, math.MaxUint32, nil) + } + } else { + uidRemapIDs := lookupRanges(container, ContainerGroup, KeyRemapUIDRanges, lookupHostSubuid, getDefaultRemapUids()) + gidRemapIDs := lookupRanges(container, ContainerGroup, KeyRemapGIDRanges, lookupHostSubgid, getDefaultRemapGids()) + remapUIDStart := container.LookupUint32(ContainerGroup, KeyRemapUIDStart, 1) + remapGIDStart := container.LookupUint32(ContainerGroup, KeyRemapGIDStart, 1) + + addIDMaps(podman, "--uidmap", uid, hostUID, remapUIDStart, uidRemapIDs) + addIDMaps(podman, "--gidmap", gid, hostGID, remapGIDStart, gidRemapIDs) + } + + volumes := container.LookupAll(ContainerGroup, KeyVolume) + for _, volume := range volumes { + parts := strings.SplitN(volume, ":", 3) + + source := "" + var dest string + options := "" + if len(parts) >= 2 { + source = parts[0] + dest = parts[1] + } else { + dest = parts[0] + } + if len(parts) >= 3 { + options = ":" + parts[2] + } + + if source != "" { + if source[0] == '/' { + // Absolute path + service.Add(UnitGroup, "RequiresMountsFor", source) + } else if strings.HasSuffix(source, ".volume") { + // the podman volume name is systemd-$name + volumeName := replaceExtension(source, "", "systemd-", "") + + // the systemd unit name is $name-volume.service + volumeServiceName := replaceExtension(source, ".service", "", "-volume") + + source = volumeName + + service.Add(UnitGroup, "Requires", volumeServiceName) + service.Add(UnitGroup, "After", volumeServiceName) + } + } + + podman.add("-v") + if source == "" { + podman.add(dest) + } else { + podman.addf("%s:%s%s", source, dest, options) + } + } + + exposedPorts := container.LookupAll(ContainerGroup, KeyExposeHostPort) + for _, exposedPort := range exposedPorts { + exposedPort = strings.TrimSpace(exposedPort) // Allow whitespace after + + if !isPortRange(exposedPort) { + return nil, fmt.Errorf("invalid port format '%s'", exposedPort) + } + + podman.addf("--expose=%s", exposedPort) + } + + publishPorts := container.LookupAll(ContainerGroup, KeyPublishPort) + for _, publishPort := range publishPorts { + publishPort = strings.TrimSpace(publishPort) // Allow whitespace after + + // IP address could have colons in it. For example: "[::]:8080:80/tcp, so use custom splitter + parts := splitPorts(publishPort) + + var containerPort string + ip := "" + hostPort := "" + + // format (from podman run): + // ip:hostPort:containerPort | ip::containerPort | hostPort:containerPort | containerPort + // + // ip could be IPv6 with minimum of these chars "[::]" + // containerPort can have a suffix of "/tcp" or "/udp" + // + + switch len(parts) { + case 1: + containerPort = parts[0] + + case 2: + hostPort = parts[0] + containerPort = parts[1] + + case 3: + ip = parts[0] + hostPort = parts[1] + containerPort = parts[2] + + default: + return nil, fmt.Errorf("invalid published port '%s'", publishPort) + } + + if ip == "0.0.0.0" { + ip = "" + } + + if len(hostPort) > 0 && !isPortRange(hostPort) { + return nil, fmt.Errorf("invalid port format '%s'", hostPort) + } + + if len(containerPort) > 0 && !isPortRange(containerPort) { + return nil, fmt.Errorf("invalid port format '%s'", containerPort) + } + + switch { + case len(ip) > 0 && len(hostPort) > 0: + podman.addf("-p=%s:%s:%s", ip, hostPort, containerPort) + case len(ip) > 0: + podman.addf("-p=%s::%s", ip, containerPort) + case len(hostPort) > 0: + podman.addf("-p=%s:%s", hostPort, containerPort) + default: + podman.addf("-p=%s", containerPort) + } + } + + podman.addEnv(podmanEnv) + + labels := container.LookupAllKeyVal(ContainerGroup, KeyLabel) + podman.addLabels(labels) + + annotations := container.LookupAllKeyVal(ContainerGroup, KeyAnnotation) + podman.addAnnotations(annotations) + + podmanArgs := container.LookupAllArgs(ContainerGroup, KeyPodmanArgs) + podman.add(podmanArgs...) + + podman.add(image) + + execArgs, ok := container.LookupLastArgs(ContainerGroup, KeyExec) + if ok { + podman.add(execArgs...) + } + + service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) + + return service, nil +} + +// Convert a quadlet volume file (unit file with a Volume group) to a systemd +// service file (unit file with Service group) based on the options in the +// Volume group. +// The original Container group is kept around as X-Container. +func ConvertVolume(volume *systemdparser.UnitFile, name string) (*systemdparser.UnitFile, error) { + service := volume.Dup() + service.Filename = replaceExtension(volume.Filename, ".service", "", "-volume") + + if err := checkForUnknownKeys(volume, VolumeGroup, supportedVolumeKeys); err != nil { + return nil, err + } + + /* Rename old Volume group to x-Volume so that systemd ignores it */ + service.RenameGroup(VolumeGroup, XVolumeGroup) + + volumeName := replaceExtension(name, "", "systemd-", "") + + // Need the containers filesystem mounted to start podman + service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") + + execCond := fmt.Sprintf("/usr/bin/bash -c \"! /usr/bin/podman volume exists %s\"", volumeName) + + labels := volume.LookupAllKeyVal(VolumeGroup, "Label") + + podman := NewPodmanCmdline("volume", "create") + + var opts strings.Builder + opts.WriteString("o=") + + if volume.HasKey(VolumeGroup, "User") { + uid := volume.LookupUint32(VolumeGroup, "User", 0) + if opts.Len() > 2 { + opts.WriteString(",") + } + opts.WriteString(fmt.Sprintf("uid=%d", uid)) + } + + if volume.HasKey(VolumeGroup, "Group") { + gid := volume.LookupUint32(VolumeGroup, "Group", 0) + if opts.Len() > 2 { + opts.WriteString(",") + } + opts.WriteString(fmt.Sprintf("gid=%d", gid)) + } + + if opts.Len() > 2 { + podman.add("--opt", opts.String()) + } + + podman.addLabels(labels) + podman.add(volumeName) + + service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) + + service.Setv(ServiceGroup, + "Type", "oneshot", + "RemainAfterExit", "yes", + "ExecCondition", execCond, + + // The default syslog identifier is the exec basename (podman) which isn't very useful here + "SyslogIdentifier", "%N") + + return service, nil +} diff --git a/pkg/quadlet/quadlet_test.go b/pkg/quadlet/quadlet_test.go new file mode 100644 index 0000000000..c94678ef6f --- /dev/null +++ b/pkg/quadlet/quadlet_test.go @@ -0,0 +1,45 @@ +package quadlet + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQuadlet_SplitPorts(t *testing.T) { + parts := splitPorts("") + assert.Equal(t, len(parts), 1) + assert.Equal(t, parts[0], "") + + parts = splitPorts("foo") + assert.Equal(t, len(parts), 1) + assert.Equal(t, parts[0], "foo") + + parts = splitPorts("foo:bar") + assert.Equal(t, len(parts), 2) + assert.Equal(t, parts[0], "foo") + assert.Equal(t, parts[1], "bar") + + parts = splitPorts("foo:bar:") + assert.Equal(t, len(parts), 3) + assert.Equal(t, parts[0], "foo") + assert.Equal(t, parts[1], "bar") + assert.Equal(t, parts[2], "") + + parts = splitPorts("abc[foo::bar]xyz:foo:bar") + assert.Equal(t, len(parts), 3) + assert.Equal(t, parts[0], "abc[foo::bar]xyz") + assert.Equal(t, parts[1], "foo") + assert.Equal(t, parts[2], "bar") + + parts = splitPorts("foo:abc[foo::bar]xyz:bar") + assert.Equal(t, len(parts), 3) + assert.Equal(t, parts[0], "foo") + assert.Equal(t, parts[1], "abc[foo::bar]xyz") + assert.Equal(t, parts[2], "bar") + + parts = splitPorts("foo:abc[foo::barxyz:bar") + assert.Equal(t, len(parts), 2) + assert.Equal(t, parts[0], "foo") + assert.Equal(t, parts[1], "abc[foo::barxyz:bar") +} diff --git a/pkg/quadlet/ranges.go b/pkg/quadlet/ranges.go new file mode 100644 index 0000000000..01d850056d --- /dev/null +++ b/pkg/quadlet/ranges.go @@ -0,0 +1,249 @@ +package quadlet + +import ( + "math" + "strconv" + "strings" +) + +// The Ranges abstraction efficiently keeps track of a list of non-intersecting +// ranges of uint32. You can merge these and modify them (add/remove a range). +// The primary use of these is to manage Uid/Gid ranges for re-mapping + +func minUint32(x, y uint32) uint32 { + if x < y { + return x + } + return y +} + +func maxUint32(x, y uint32) uint32 { + if x > y { + return x + } + return y +} + +type Range struct { + Start uint32 + Length uint32 +} + +type Ranges struct { + Ranges []Range +} + +func (r *Ranges) Add(start, length uint32) { + // The maximum value we can store is UINT32_MAX-1, because if start + // is 0 and length is UINT32_MAX, then the first non-range item is + // 0+UINT32_MAX. So, we limit the start and length here so all + // elements in the ranges are in this area. + if start == math.MaxUint32 { + return + } + length = minUint32(length, math.MaxUint32-start) + + if length == 0 { + return + } + + for i := 0; i < len(r.Ranges); i++ { + current := &r.Ranges[i] + // Check if new range starts before current + if start < current.Start { + // Check if new range is completely before current + if start+length < current.Start { + // insert new range at i + newr := make([]Range, len(r.Ranges)+1) + copy(newr[0:i], r.Ranges[0:i]) + newr[i] = Range{Start: start, Length: length} + copy(newr[i+1:], r.Ranges[i:]) + r.Ranges = newr + + return // All done + } + + // ranges overlap, extend current backward to new start + toExtendLen := current.Start - start + current.Start -= toExtendLen + current.Length += toExtendLen + + // And drop the extended part from new range + start += toExtendLen + length -= toExtendLen + + if length == 0 { + return // That was all + } + + // Move on to next case + } + + if start >= current.Start && start < current.Start+current.Length { + // New range overlaps current + if start+length <= current.Start+current.Length { + return // All overlapped, we're done + } + + // New range extends past end of current + overlapLen := (current.Start + current.Length) - start + + // And drop the overlapped part from current range + start += overlapLen + length -= overlapLen + + // Move on to next case + } + + if start == current.Start+current.Length { + // We're extending current + current.Length += length + + // Might have to merge some old remaining ranges + for i+1 < len(r.Ranges) && + r.Ranges[i+1].Start <= current.Start+current.Length { + next := &r.Ranges[i+1] + + newEnd := maxUint32(current.Start+current.Length, next.Start+next.Length) + + current.Length = newEnd - current.Start + + copy(r.Ranges[i+1:], r.Ranges[i+2:]) + r.Ranges = r.Ranges[:len(r.Ranges)-1] + current = &r.Ranges[i] + } + + return // All done + } + } + + // New range remaining after last old range, append + if length > 0 { + r.Ranges = append(r.Ranges, Range{Start: start, Length: length}) + } +} + +func (r *Ranges) Remove(start, length uint32) { + // Limit ranges, see comment in Add + if start == math.MaxUint32 { + return + } + length = minUint32(length, math.MaxUint32-start) + + if length == 0 { + return + } + + for i := 0; i < len(r.Ranges); i++ { + current := &r.Ranges[i] + + end := start + length + currentStart := current.Start + currentEnd := current.Start + current.Length + + if end > currentStart && start < currentEnd { + remainingAtStart := uint32(0) + remainingAtEnd := uint32(0) + + if start > currentStart { + remainingAtStart = start - currentStart + } + + if end < currentEnd { + remainingAtEnd = currentEnd - end + } + + switch { + case remainingAtStart == 0 && remainingAtEnd == 0: + // Remove whole range + copy(r.Ranges[i:], r.Ranges[i+1:]) + r.Ranges = r.Ranges[:len(r.Ranges)-1] + i-- // undo loop iter + case remainingAtStart != 0 && remainingAtEnd != 0: + // Range is split + + newr := make([]Range, len(r.Ranges)+1) + copy(newr[0:i], r.Ranges[0:i]) + copy(newr[i+1:], r.Ranges[i:]) + newr[i].Start = currentStart + newr[i].Length = remainingAtStart + newr[i+1].Start = currentEnd - remainingAtEnd + newr[i+1].Length = remainingAtEnd + r.Ranges = newr + i++ /* double loop iter */ + case remainingAtStart != 0: + r.Ranges[i].Start = currentStart + r.Ranges[i].Length = remainingAtStart + default: /* remainingAtEnd != 0 */ + r.Ranges[i].Start = currentEnd - remainingAtEnd + r.Ranges[i].Length = remainingAtEnd + } + } + } +} + +func (r *Ranges) Merge(other *Ranges) { + for _, o := range other.Ranges { + r.Add(o.Start, o.Length) + } +} + +func (r *Ranges) Copy() *Ranges { + rs := make([]Range, len(r.Ranges)) + copy(rs, r.Ranges) + return &Ranges{Ranges: rs} +} + +func (r *Ranges) Length() uint32 { + length := uint32(0) + for _, rr := range r.Ranges { + length += rr.Length + } + return length +} + +func NewRangesEmpty() *Ranges { + return &Ranges{Ranges: nil} +} + +func NewRanges(start, length uint32) *Ranges { + r := NewRangesEmpty() + r.Add(start, length) + + return r +} + +func parseEndpoint(str string, defaultVal uint32) uint32 { + str = strings.TrimSpace(str) + intVal, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return defaultVal + } + + if intVal < 0 { + return uint32(0) + } + if intVal > math.MaxUint32 { + return uint32(math.MaxUint32) + } + return uint32(intVal) +} + +// Ranges are specified inclusive. I.e. 1-3 is 1,2,3 +func ParseRanges(str string) *Ranges { + r := NewRangesEmpty() + + for _, part := range strings.Split(str, ",") { + start, end, isPair := strings.Cut(part, "-") + startV := parseEndpoint(start, 0) + endV := startV + if isPair { + endV = parseEndpoint(end, math.MaxUint32) + } + if endV >= startV { + r.Add(startV, endV-startV+1) + } + } + + return r +} diff --git a/pkg/quadlet/ranges_test.go b/pkg/quadlet/ranges_test.go new file mode 100644 index 0000000000..b738c7dc42 --- /dev/null +++ b/pkg/quadlet/ranges_test.go @@ -0,0 +1,242 @@ +package quadlet + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRanges_Creation(t *testing.T) { + empty := NewRangesEmpty() + + assert.Equal(t, empty.Length(), uint32(0)) + + one := NewRanges(17, 42) + assert.Equal(t, one.Ranges[0].Start, uint32(17)) + assert.Equal(t, one.Ranges[0].Length, uint32(42)) +} + +func TestRanges_Single(t *testing.T) { + /* Before */ + r := NewRanges(10, 10) + + r.Add(0, 9) + + assert.Equal(t, len(r.Ranges), 2) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(9)) + assert.Equal(t, r.Ranges[1].Start, uint32(10)) + assert.Equal(t, r.Ranges[1].Length, uint32(10)) + + /* just before */ + r = NewRanges(10, 10) + + r.Add(0, 10) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(20)) + + /* before + inside */ + r = NewRanges(10, 10) + + r.Add(0, 19) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(20)) + + /* before + inside, whole */ + r = NewRanges(10, 10) + + r.Add(0, 20) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(20)) + + /* before + inside + after */ + r = NewRanges(10, 10) + + r.Add(0, 30) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(30)) + + /* just inside */ + r = NewRanges(10, 10) + + r.Add(10, 5) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + + /* inside */ + r = NewRanges(10, 10) + + r.Add(12, 5) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + + /* inside at end */ + r = NewRanges(10, 10) + + r.Add(15, 5) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + + /* inside + after */ + r = NewRanges(10, 10) + + r.Add(15, 10) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(15)) + + /* just after */ + r = NewRanges(10, 10) + + r.Add(20, 10) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(20)) + + /* after */ + r = NewRanges(10, 10) + + r.Add(21, 10) + + assert.Equal(t, len(r.Ranges), 2) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(21)) + assert.Equal(t, r.Ranges[1].Length, uint32(10)) +} + +func TestRanges_Multi(t *testing.T) { + base := NewRanges(10, 10) + base.Add(50, 10) + base.Add(30, 10) + + /* Test copy */ + r := base.Copy() + + assert.Equal(t, len(r.Ranges), 3) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(30)) + assert.Equal(t, r.Ranges[1].Length, uint32(10)) + assert.Equal(t, r.Ranges[2].Start, uint32(50)) + assert.Equal(t, r.Ranges[2].Length, uint32(10)) + + /* overlap everything */ + r = base.Copy() + + r.Add(0, 100) + + assert.Equal(t, len(r.Ranges), 1) + assert.Equal(t, r.Ranges[0].Start, uint32(0)) + assert.Equal(t, r.Ranges[0].Length, uint32(100)) + + /* overlap middle */ + r = base.Copy() + + r.Add(25, 10) + + assert.Equal(t, len(r.Ranges), 3) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(25)) + assert.Equal(t, r.Ranges[1].Length, uint32(15)) + assert.Equal(t, r.Ranges[2].Start, uint32(50)) + assert.Equal(t, r.Ranges[2].Length, uint32(10)) + + /* overlap last */ + r = base.Copy() + + r.Add(45, 10) + + assert.Equal(t, len(r.Ranges), 3) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(30)) + assert.Equal(t, r.Ranges[1].Length, uint32(10)) + assert.Equal(t, r.Ranges[2].Start, uint32(45)) + assert.Equal(t, r.Ranges[2].Length, uint32(15)) +} + +func TestRanges_Remove(t *testing.T) { + base := NewRanges(10, 10) + base.Add(50, 10) + base.Add(30, 10) + + /* overlap all */ + r := base.Copy() + + r.Remove(0, 100) + + assert.Equal(t, len(r.Ranges), 0) + + /* overlap middle 1 */ + + r = base.Copy() + + r.Remove(25, 20) + + assert.Equal(t, len(r.Ranges), 2) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(50)) + assert.Equal(t, r.Ranges[1].Length, uint32(10)) + + /* overlap middle 2 */ + + r = base.Copy() + + r.Remove(25, 10) + + assert.Equal(t, len(r.Ranges), 3) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(35)) + assert.Equal(t, r.Ranges[1].Length, uint32(5)) + assert.Equal(t, r.Ranges[2].Start, uint32(50)) + assert.Equal(t, r.Ranges[2].Length, uint32(10)) + + /* overlap middle 3 */ + r = base.Copy() + + r.Remove(35, 10) + + assert.Equal(t, len(r.Ranges), 3) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(30)) + assert.Equal(t, r.Ranges[1].Length, uint32(5)) + assert.Equal(t, r.Ranges[2].Start, uint32(50)) + assert.Equal(t, r.Ranges[2].Length, uint32(10)) + + /* overlap middle 4 */ + + r = base.Copy() + + r.Remove(34, 2) + + assert.Equal(t, len(r.Ranges), 4) + assert.Equal(t, r.Ranges[0].Start, uint32(10)) + assert.Equal(t, r.Ranges[0].Length, uint32(10)) + assert.Equal(t, r.Ranges[1].Start, uint32(30)) + assert.Equal(t, r.Ranges[1].Length, uint32(4)) + assert.Equal(t, r.Ranges[2].Start, uint32(36)) + assert.Equal(t, r.Ranges[2].Length, uint32(4)) + assert.Equal(t, r.Ranges[3].Start, uint32(50)) + assert.Equal(t, r.Ranges[3].Length, uint32(10)) +} diff --git a/pkg/quadlet/subuids.go b/pkg/quadlet/subuids.go new file mode 100644 index 0000000000..6701605472 --- /dev/null +++ b/pkg/quadlet/subuids.go @@ -0,0 +1,69 @@ +package quadlet + +import ( + "os" + "strconv" + "strings" +) + +// Code to look up subuid/subguid allocations for a user in /etc/subuid and /etc/subgid + +func lookupHostSubid(name string, file string, cache *[]string) *Ranges { + ranges := NewRangesEmpty() + + if len(*cache) == 0 { + data, e := os.ReadFile(file) + if e != nil { + *cache = make([]string, 0) + } else { + *cache = strings.Split(string(data), "\n") + } + for i := range *cache { + (*cache)[i] = strings.TrimSpace((*cache)[i]) + } + + // If file had no lines, add an empty line so the above cache created check works + if len(*cache) == 0 { + *cache = append(*cache, "") + } + } + + for _, line := range *cache { + if strings.HasPrefix(line, name) && + len(line) > len(name)+1 && line[len(name)] == ':' { + parts := strings.SplitN(line, ":", 3) + + if len(parts) != 3 { + continue + } + + start, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + continue + } + + len, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + continue + } + + if len > 0 { + ranges.Add(uint32(start), uint32(len)) + } + + break + } + } + + return ranges +} + +var subuidCache, subgidCache []string + +func lookupHostSubuid(userName string) *Ranges { + return lookupHostSubid(userName, "/etc/subuid", &subuidCache) +} + +func lookupHostSubgid(userName string) *Ranges { + return lookupHostSubid(userName, "/etc/subgid", &subgidCache) +} diff --git a/pkg/systemdparser/split.go b/pkg/systemdparser/split.go new file mode 100644 index 0000000000..3ff3d83a55 --- /dev/null +++ b/pkg/systemdparser/split.go @@ -0,0 +1,505 @@ +package systemdparser + +import ( + "fmt" + "strings" + "unicode" +) + +/* Functions to split/join, unescape/escape strings similar to Exec=... lines in unit files */ + +type SplitFlags = uint64 + +const ( + SplitRelax SplitFlags = 1 << iota // Allow unbalanced quote and eat up trailing backslash. + SplitCUnescape // Unescape known escape sequences. + SplitUnescapeRelax // Allow and keep unknown escape sequences, allow and keep trailing backslash. + SplitUnescapeSeparators // Unescape separators (those specified, or whitespace by default). + SplitKeepQuote // Ignore separators in quoting with "" and ''. + SplitUnquote // Ignore separators in quoting with "" and '', and remove the quotes. + SplitDontCoalesceSeparators // Don't treat multiple adjacent separators as one + SplitRetainEscape // Treat escape character '\' as any other character without special meaning + SplitRetainSeparators // Do not advance the original string pointer past the separator(s) */ +) + +const WhitespaceSeparators = " \t\n\r" + +func unoctchar(v byte) int { + if v >= '0' && v <= '7' { + return int(v - '0') + } + + return -1 +} + +func unhexchar(v byte) int { + if v >= '0' && v <= '9' { + return int(v - '0') + } + + if v >= 'a' && v <= 'f' { + return int(v - 'a' + 10) + } + + if v >= 'A' && v <= 'F' { + return int(v - 'A' + 10) + } + + return -1 +} + +func isValidUnicode(c uint32) bool { + return c <= unicode.MaxRune +} + +/* This is based on code from systemd (src/basic/escape.c), marked LGPL-2.1-or-later and is copyrighted by the systemd developers */ + +func cUnescapeOne(p string, acceptNul bool) (int, rune, bool) { + var count = 1 + var eightBit = false + var ret rune + + // Unescapes C style. Returns the unescaped character in ret. + // Returns eightBit as true if the escaped sequence either fits in + // one byte in UTF-8 or is a non-unicode literal byte and should + // instead be copied directly. + + if len(p) < 1 { + return -1, 0, false + } + + switch p[0] { + case 'a': + ret = '\a' + case 'b': + ret = '\b' + case 'f': + ret = '\f' + case 'n': + ret = '\n' + case 'r': + ret = '\r' + case 't': + ret = '\t' + case 'v': + ret = '\v' + case '\\': + ret = '\\' + case '"': + ret = '"' + case '\'': + ret = '\'' + case 's': + /* This is an extension of the XDG syntax files */ + ret = ' ' + case 'x': + /* hexadecimal encoding */ + if len(p) < 3 { + return -1, 0, false + } + + a := unhexchar(p[1]) + if a < 0 { + return -1, 0, false + } + + b := unhexchar(p[2]) + if b < 0 { + return -1, 0, false + } + + /* Don't allow NUL bytes */ + if a == 0 && b == 0 && !acceptNul { + return -1, 0, false + } + + ret = rune((a << 4) | b) + eightBit = true + count = 3 + case 'u': + /* C++11 style 16bit unicode */ + + if len(p) < 5 { + return -1, 0, false + } + + var a [4]int + for i := 0; i < 4; i++ { + a[i] = unhexchar(p[1+i]) + if a[i] < 0 { + return -1, 0, false + } + } + + c := (uint32(a[0]) << 12) | (uint32(a[1]) << 8) | (uint32(a[2]) << 4) | uint32(a[3]) + + /* Don't allow 0 chars */ + if c == 0 && !acceptNul { + return -1, 0, false + } + + ret = rune(c) + count = 5 + case 'U': + /* C++11 style 32bit unicode */ + + if len(p) < 9 { + return -1, 0, false + } + + var a [8]int + for i := 0; i < 8; i++ { + a[i] = unhexchar(p[1+i]) + if a[i] < 0 { + return -10, 0, false + } + } + + c := (uint32(a[0]) << 28) | (uint32(a[1]) << 24) | (uint32(a[2]) << 20) | (uint32(a[3]) << 16) | + (uint32(a[4]) << 12) | (uint32(a[5]) << 8) | (uint32(a[6]) << 4) | uint32(a[7]) + + /* Don't allow 0 chars */ + if c == 0 && !acceptNul { + return -1, 0, false + } + + /* Don't allow invalid code points */ + if !isValidUnicode(c) { + return -1, 0, false + } + + ret = rune(c) + count = 9 + case '0': + case '1': + case '2': + case '3': + case '4': + case '5': + case '6': + case '7': + /* octal encoding */ + + if len(p) < 3 { + return -1, 0, false + } + + a := unoctchar(p[0]) + if a < 0 { + return -1, 0, false + } + + b := unoctchar(p[0]) + if b < 0 { + return -1, 0, false + } + + c := unoctchar(p[0]) + if c < 0 { + return -1, 0, false + } + + /* don't allow NUL bytes */ + if a == 0 && b == 0 && c == 0 && !acceptNul { + return -1, 0, false + } + + /* Don't allow bytes above 255 */ + m := (uint32(a) << 6) | (uint32(b) << 3) | uint32(c) + if m > 255 { + return -1, 0, false + } + + ret = rune(m) + eightBit = true + count = 3 + default: + return -1, 0, false + } + + return count, ret, eightBit +} + +/* This is based on code from systemd (src/basic/extract-workd.c), marked LGPL-2.1-or-later and is copyrighted by the systemd developers */ + +// Returns: word, remaining, more-words, error +func extractFirstWord(in string, separators string, flags SplitFlags) (string, string, bool, error) { + var s strings.Builder + var quote byte // 0 or ' or " + backslash := false // whether we've just seen a backslash + + // The string handling in this function is a bit weird, using + // 0 bytes to mark end-of-string. This is because its a direct + // conversion of the C in systemd, and w want to ensure + // exactly the same behaviour of some complex code + + p := 0 + end := len(in) + var c byte + + nextChar := func() byte { + p++ + if p >= end { + return 0 + } + return in[p] + } + + /* Bail early if called after last value or with no input */ + if len(in) == 0 { + goto finish + } + + // Parses the first word of a string, and returns it and the + // remainder. Removes all quotes in the process. When parsing + // fails (because of an uneven number of quotes or similar), + // the rest is at the first invalid character. */ + +loop1: + for c = in[0]; ; c = nextChar() { + switch { + case c == 0: + goto finishForceTerminate + case strings.ContainsRune(separators, rune(c)): + if flags&SplitDontCoalesceSeparators != 0 { + if !(flags&SplitRetainSeparators != 0) { + p++ + } + goto finishForceNext + } + default: + // We found a non-blank character, so we will always + // want to return a string (even if it is empty), + // allocate it here. + break loop1 + } + } + + for ; ; c = nextChar() { + switch { + case backslash: + if c == 0 { + if flags&SplitUnescapeRelax != 0 && + (quote == 0 || flags&SplitRelax != 0) { + // If we find an unquoted trailing backslash and we're in + // SplitUnescapeRelax mode, keep it verbatim in the + // output. + // + // Unbalanced quotes will only be allowed in SplitRelax + // mode, SplitUnescapeRelax mode does not allow them. + s.WriteString("\\") + goto finishForceTerminate + } + if flags&SplitRelax != 0 { + goto finishForceTerminate + } + return "", "", false, fmt.Errorf("unbalanced escape") + } + + if flags&(SplitCUnescape|SplitUnescapeSeparators) != 0 { + var r = -1 + var u rune + + if flags&SplitCUnescape != 0 { + r, u, _ = cUnescapeOne(in[p:], false) + } + + switch { + case r > 0: + p += r - 1 + s.WriteRune(u) + case (flags&SplitUnescapeSeparators != 0) && + (strings.ContainsRune(separators, rune(c)) || c == '\\'): + /* An escaped separator char or the escape char itself */ + s.WriteByte(c) + case flags&SplitUnescapeRelax != 0: + s.WriteByte('\\') + s.WriteByte(c) + default: + return "", "", false, fmt.Errorf("unsupported escape char") + } + } else { + s.WriteByte(c) + } + + backslash = false + case quote != 0: + /* inside either single or double quotes */ + quoteloop: + for ; ; c = nextChar() { + switch { + case c == 0: + if flags&SplitRelax != 0 { + goto finishForceTerminate + } + return "", "", false, fmt.Errorf("unbalanced quotes") + case c == quote: + /* found the end quote */ + quote = 0 + if flags&SplitUnquote != 0 { + break quoteloop + } + case c == '\\' && !(flags&SplitRetainEscape != 0): + backslash = true + break quoteloop + } + + s.WriteByte(c) + + if quote == 0 { + break quoteloop + } + } + default: + nonquoteloop: + for ; ; c = nextChar() { + switch { + case c == 0: + goto finishForceTerminate + case (c == '\'' || c == '"') && (flags&(SplitKeepQuote|SplitUnquote) != 0): + quote = c + if flags&SplitUnquote != 0 { + break nonquoteloop + } + case c == '\\' && !(flags&SplitRetainEscape != 0): + backslash = true + break nonquoteloop + case strings.ContainsRune(separators, rune(c)): + if flags&SplitDontCoalesceSeparators != 0 { + if !(flags&SplitRetainSeparators != 0) { + p++ + } + goto finishForceNext + } + + if !(flags&SplitRetainSeparators != 0) { + /* Skip additional coalesced separators. */ + for ; ; c = nextChar() { + if c == 0 { + goto finishForceTerminate + } + if !strings.ContainsRune(separators, rune(c)) { + break + } + } + } + goto finish + } + + s.WriteByte(c) + + if quote != 0 { + break nonquoteloop + } + } + } + } + +finishForceTerminate: + p = end + +finish: + if s.Len() == 0 { + return "", "", false, nil + } + +finishForceNext: + return s.String(), in[p:], true, nil +} + +func splitStringAppend(appendTo []string, s string, separators string, flags SplitFlags) ([]string, error) { + orig := appendTo + for { + word, remaining, moreWords, err := extractFirstWord(s, separators, flags) + if err != nil { + return orig, err + } + + if !moreWords { + break + } + appendTo = append(appendTo, word) + s = remaining + } + return appendTo, nil +} + +func splitString(s string, separators string, flags SplitFlags) ([]string, error) { + return splitStringAppend(make([]string, 0), s, separators, flags) +} + +func charNeedEscape(c rune) bool { + if c > 128 { + return false /* unicode is ok */ + } + + return unicode.IsSpace(c) || + unicode.IsControl(c) || + c == '"' || + c == '\'' || + c == '\\' +} + +func wordNeedEscape(word string) bool { + for _, c := range word { + if charNeedEscape(c) { + return true + } + } + + return false +} + +func appendEscapeWord(escaped *strings.Builder, word string) { + escaped.WriteRune('"') + for _, c := range word { + if charNeedEscape(c) { + switch c { + case '\a': + escaped.WriteString("\\a") + case '\b': + escaped.WriteString("\\b") + case '\n': + escaped.WriteString("\\n") + case '\r': + escaped.WriteString("\\r") + case '\t': + escaped.WriteString("\\t") + case '\v': + escaped.WriteString("\\v") + case '\f': + escaped.WriteString("\\f") + case '\\': + escaped.WriteString("\\\\") + case ' ': + escaped.WriteString(" ") + case '"': + escaped.WriteString("\\\"") + case '\'': + escaped.WriteString("'") + default: + escaped.WriteString(fmt.Sprintf("\\x%.2x", c)) + } + } else { + escaped.WriteRune(c) + } + } + escaped.WriteRune('"') +} + +func escapeWords(words []string) string { + var escaped strings.Builder + + for i, word := range words { + if i != 0 { + escaped.WriteString(" ") + } + if wordNeedEscape(word) { + appendEscapeWord(&escaped, word) + } else { + escaped.WriteString(word) + } + } + + return escaped.String() +} diff --git a/pkg/systemdparser/unitfile.go b/pkg/systemdparser/unitfile.go new file mode 100644 index 0000000000..125ef9a26d --- /dev/null +++ b/pkg/systemdparser/unitfile.go @@ -0,0 +1,900 @@ +package systemdparser + +import ( + "fmt" + "io" + "math" + "os" + "os/user" + "path" + "strconv" + "strings" + "unicode" +) + +// This code (UnitFile) support reading well formed files in the same +// format as the systmed unit files. It can also regenerate the file +// essentially identically, including comments and group/key order. +// The only thing that is modified is that multiple instances of one +// group are merged. + +// There is also support for reading and modifying keys while the +// UnitFile is in memory, including support for systemd-like slitting +// of argument lines and escaping/unescaping of text. + +type unitLine struct { + key string + value string + isComment bool +} + +type unitGroup struct { + name string + comments []*unitLine // Comments before the groupname + lines []*unitLine +} + +type UnitFile struct { + groups []*unitGroup + groupByName map[string]*unitGroup + + Filename string + Path string +} + +type UnitFileParser struct { + file *UnitFile + + currentGroup *unitGroup + pendingComments []*unitLine + lineNr int +} + +func newUnitLine(key string, value string, isComment bool) *unitLine { + l := &unitLine{ + key: key, + value: value, + isComment: isComment, + } + return l +} + +func (l *unitLine) set(value string) { + l.value = value +} + +func (l *unitLine) dup() *unitLine { + return newUnitLine(l.key, l.value, l.isComment) +} + +func (l *unitLine) isKey(key string) bool { + return !l.isComment && + l.key == key +} + +func (l *unitLine) isEmpty() bool { + return len(l.value) == 0 +} + +func newUnitGroup(name string) *unitGroup { + g := &unitGroup{ + name: name, + comments: make([]*unitLine, 0), + lines: make([]*unitLine, 0), + } + return g +} + +func (g *unitGroup) addLine(line *unitLine) { + g.lines = append(g.lines, line) +} + +func (g *unitGroup) addComment(line *unitLine) { + g.comments = append(g.comments, line) +} + +func (g *unitGroup) prependComment(line *unitLine) { + n := []*unitLine{line} + g.comments = append(n, g.comments...) +} + +func (g *unitGroup) add(key string, value string) { + g.addLine(newUnitLine(key, value, false)) +} + +func (g *unitGroup) findLast(key string) *unitLine { + for i := len(g.lines) - 1; i >= 0; i-- { + l := g.lines[i] + if l.isKey(key) { + return l + } + } + + return nil +} + +func (g *unitGroup) set(key string, value string) { + line := g.findLast(key) + if line != nil { + line.set(value) + } else { + g.add(key, value) + } +} + +func (g *unitGroup) unset(key string) { + newlines := make([]*unitLine, 0, len(g.lines)) + + for _, line := range g.lines { + if !line.isKey(key) { + newlines = append(newlines, line) + } + } + g.lines = newlines +} + +func (g *unitGroup) merge(source *unitGroup) { + for _, l := range source.comments { + g.comments = append(g.comments, l.dup()) + } + for _, l := range source.lines { + g.lines = append(g.lines, l.dup()) + } +} + +// Create an empty unit file, with no filename or path +func NewUnitFile() *UnitFile { + f := &UnitFile{ + groups: make([]*unitGroup, 0), + groupByName: make(map[string]*unitGroup), + } + + return f +} + +// Load a unit file from disk, remembering the path and filename +func ParseUnitFile(pathName string) (*UnitFile, error) { + data, e := os.ReadFile(pathName) + if e != nil { + return nil, e + } + + f := NewUnitFile() + f.Path = pathName + f.Filename = path.Base(pathName) + + if e := f.Parse(string(data)); e != nil { + return nil, e + } + + return f, nil +} + +func (f *UnitFile) ensureGroup(groupName string) *unitGroup { + if g, ok := f.groupByName[groupName]; ok { + return g + } + + g := newUnitGroup(groupName) + f.groups = append(f.groups, g) + f.groupByName[groupName] = g + + return g +} + +func (f *UnitFile) merge(source *UnitFile) { + for _, srcGroup := range source.groups { + group := f.ensureGroup(srcGroup.name) + group.merge(srcGroup) + } +} + +// Create a copy of the unit file, copies filename but not path +func (f *UnitFile) Dup() *UnitFile { + copy := NewUnitFile() + + copy.merge(f) + copy.Filename = f.Filename + return copy +} + +func lineIsComment(line string) bool { + return len(line) == 0 || line[0] == '#' || line[0] == ':' +} + +func lineIsGroup(line string) bool { + if len(line) == 0 { + return false + } + + if line[0] != '[' { + return false + } + + end := strings.Index(line, "]") + if end == -1 { + return false + } + + // silently accept whitespace after the ] + for i := end + 1; i < len(line); i++ { + if line[i] != ' ' && line[i] != '\t' { + return false + } + } + + return true +} + +func lineIsKeyValuePair(line string) bool { + if len(line) == 0 { + return false + } + + p := strings.IndexByte(line, '=') + if p == -1 { + return false + } + + // Key must be non-empty + if p == 0 { + return false + } + + return true +} + +func groupNameIsValid(name string) bool { + if len(name) == 0 { + return false + } + + for _, c := range name { + if c == ']' || c == '[' || unicode.IsControl(c) { + return false + } + } + + return true +} + +func keyNameIsValid(name string) bool { + if len(name) == 0 { + return false + } + + for _, c := range name { + if c == '=' { + return false + } + } + + // No leading/trailing space + if name[0] == ' ' || name[len(name)-1] == ' ' { + return false + } + + return true +} + +func (p *UnitFileParser) parseComment(line string) error { + l := newUnitLine("", line, true) + p.pendingComments = append(p.pendingComments, l) + return nil +} + +func (p *UnitFileParser) parseGroup(line string) error { + end := strings.Index(line, "]") + + groupName := line[1:end] + + if !groupNameIsValid(groupName) { + return fmt.Errorf("invalid group name: %s", groupName) + } + + p.currentGroup = p.file.ensureGroup(groupName) + + if p.pendingComments != nil { + firstComment := p.pendingComments[0] + + // Remove one newline between groups, which is re-added on + // printing, see unitGroup.Write() + if firstComment.isEmpty() { + p.pendingComments = p.pendingComments[1:] + } + + p.flushPendingComments(true) + } + + return nil +} + +func (p *UnitFileParser) parseKeyValuePair(line string) error { + if p.currentGroup == nil { + return fmt.Errorf("key file does not start with a group") + } + + keyEnd := strings.Index(line, "=") + valueStart := keyEnd + 1 + + // Pull the key name from the line (chomping trailing whitespace) + for keyEnd > 0 && unicode.IsSpace(rune(line[keyEnd-1])) { + keyEnd-- + } + key := line[:keyEnd] + if !keyNameIsValid(key) { + return fmt.Errorf("invalid key name: %s", key) + } + + // Pull the value from the line (chugging leading whitespace) + + for valueStart < len(line) && unicode.IsSpace(rune(line[valueStart])) { + valueStart++ + } + + value := line[valueStart:] + + p.flushPendingComments(false) + + p.currentGroup.add(key, value) + + return nil +} + +func (p *UnitFileParser) parseLine(line string) error { + switch { + case lineIsComment(line): + return p.parseComment(line) + case lineIsGroup(line): + return p.parseGroup(line) + case lineIsKeyValuePair(line): + return p.parseKeyValuePair(line) + default: + return fmt.Errorf("file contains line %d: ā€œ%sā€ which is not a key-value pair, group, or comment", p.lineNr, line) + } +} + +func (p *UnitFileParser) flushPendingComments(toComment bool) { + pending := p.pendingComments + if pending == nil { + return + } + p.pendingComments = nil + + for _, pendingLine := range pending { + if toComment { + p.currentGroup.addComment(pendingLine) + } else { + p.currentGroup.addLine(pendingLine) + } + } +} + +func nextLine(data string, afterPos int) (string, string) { + rest := data[afterPos:] + if i := strings.Index(rest, "\n"); i >= 0 { + return data[:i+afterPos], data[i+afterPos+1:] + } + return data, "" +} + +// Parse an already loaded unit file (in the form of a string) +func (f *UnitFile) Parse(data string) error { + p := &UnitFileParser{ + file: f, + lineNr: 1, + } + for len(data) > 0 { + origdata := data + nLines := 1 + var line string + line, data = nextLine(data, 0) + + // Handle multi-line continuations + // Note: This doesn't support coments in the middle of the continuation, which systemd does + if lineIsKeyValuePair(line) { + for len(data) > 0 && line[len(line)-1] == '\\' { + line, data = nextLine(origdata, len(line)+1) + nLines++ + } + } + + if err := p.parseLine(line); err != nil { + return err + } + + p.lineNr += nLines + } + + if p.currentGroup == nil { + // For files without groups, add an empty group name used only for initial comments + p.currentGroup = p.file.ensureGroup("") + } + p.flushPendingComments(false) + + return nil +} + +func (l *unitLine) write(w io.Writer) error { + if l.isComment { + if _, err := fmt.Fprintf(w, "%s\n", l.value); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(w, "%s=%s\n", l.key, l.value); err != nil { + return err + } + } + + return nil +} + +func (g *unitGroup) write(w io.Writer) error { + for _, c := range g.comments { + if err := c.write(w); err != nil { + return err + } + } + + if g.name == "" { + // Empty name groups are not valid, but used interally to handle comments in empty files + return nil + } + + if _, err := fmt.Fprintf(w, "[%s]\n", g.name); err != nil { + return err + } + + for _, l := range g.lines { + if err := l.write(w); err != nil { + return err + } + } + + return nil +} + +// Covert a UnitFile back to data, writing to the io.Writer w +func (f *UnitFile) Write(w io.Writer) error { + for i, g := range f.groups { + // We always add a newline between groups, and strip one if it exists during + // parsing. This looks nicer, and avoids issues of duplicate newlines when + // merging groups or missing ones when creating new groups + if i != 0 { + if _, err := io.WriteString(w, "\n"); err != nil { + return err + } + } + + if err := g.write(w); err != nil { + return err + } + } + + return nil +} + +// Covert a UnitFile back to data, as a string +func (f *UnitFile) ToString() (string, error) { + var str strings.Builder + if err := f.Write(&str); err != nil { + return "", err + } + return str.String(), nil +} + +func applyLineContinuation(raw string) string { + if !strings.Contains(raw, "\\\n") { + return raw + } + + var str strings.Builder + + for len(raw) > 0 { + if first, rest, found := strings.Cut(raw, "\\\n"); found { + str.WriteString(first) + raw = rest + } else { + str.WriteString(raw) + raw = "" + } + } + + return str.String() +} + +func (f *UnitFile) HasGroup(groupName string) bool { + _, ok := f.groupByName[groupName] + return ok +} + +func (f *UnitFile) RemoveGroup(groupName string) { + g, ok := f.groupByName[groupName] + if ok { + delete(f.groupByName, groupName) + + newgroups := make([]*unitGroup, 0, len(f.groups)) + for _, oldgroup := range f.groups { + if oldgroup != g { + newgroups = append(newgroups, oldgroup) + } + } + f.groups = newgroups + } +} + +func (f *UnitFile) RenameGroup(groupName string, newName string) { + group, okOld := f.groupByName[groupName] + if !okOld { + return + } + + newGroup, okNew := f.groupByName[newName] + if !okNew { + // New group doesn't exist, just rename in-place + delete(f.groupByName, groupName) + group.name = newName + f.groupByName[newName] = group + } else if group != newGroup { + /* merge to existing group and delete old */ + newGroup.merge(group) + f.RemoveGroup(groupName) + } +} + +func (f *UnitFile) ListGroups() []string { + groups := make([]string, len(f.groups)) + for i, group := range f.groups { + groups[i] = group.name + } + return groups +} + +func (f *UnitFile) ListKeys(groupName string) []string { + g, ok := f.groupByName[groupName] + if !ok { + return make([]string, 0) + } + + hash := make(map[string]struct{}) + keys := make([]string, 0, len(g.lines)) + for _, line := range g.lines { + if !line.isComment { + if _, ok := hash[line.key]; !ok { + keys = append(keys, line.key) + hash[line.key] = struct{}{} + } + } + } + + return keys +} + +// Look up the last instance of the named key in the group (if any) +// The result can have trailing whitespace, and Raw means it can +// contain line continuations (\ at end of line) +func (f *UnitFile) LookupLastRaw(groupName string, key string) (string, bool) { + g, ok := f.groupByName[groupName] + if !ok { + return "", false + } + + line := g.findLast(key) + if line == nil { + return "", false + } + + return line.value, true +} + +func (f *UnitFile) HasKey(groupName string, key string) bool { + _, ok := f.LookupLastRaw(groupName, key) + return ok +} + +// Look up the last instance of the named key in the group (if any) +// The result can have trailing whitespace, but line continuations are applied +func (f *UnitFile) LookupLast(groupName string, key string) (string, bool) { + raw, ok := f.LookupLastRaw(groupName, key) + if !ok { + return "", false + } + + return applyLineContinuation(raw), true +} + +// Look up the last instance of the named key in the group (if any) +// The result have no trailing whitespace and line continuations are applied +func (f *UnitFile) Lookup(groupName string, key string) (string, bool) { + v, ok := f.LookupLast(groupName, key) + if !ok { + return "", false + } + + return strings.TrimRightFunc(v, unicode.IsSpace), true +} + +// Lookup the last instance of a key and convert the value to a bool +func (f *UnitFile) LookupBoolean(groupName string, key string, defaultValue bool) bool { + v, ok := f.Lookup(groupName, key) + if !ok { + return defaultValue + } + + return strings.EqualFold(v, "1") || + strings.EqualFold(v, "yes") || + strings.EqualFold(v, "true") || + strings.EqualFold(v, "on") +} + +/* Mimics strol, which is what systemd uses */ +func convertNumber(v string) (int64, error) { + var err error + var intVal int64 + + mult := int64(1) + + if strings.HasPrefix(v, "+") { + v = v[1:] + } else if strings.HasPrefix(v, "-") { + v = v[1:] + mult = int64(-11) + } + + switch { + case strings.HasPrefix(v, "0x") || strings.HasPrefix(v, "0X"): + intVal, err = strconv.ParseInt(v[2:], 16, 64) + case strings.HasPrefix(v, "0"): + intVal, err = strconv.ParseInt(v, 8, 64) + default: + intVal, err = strconv.ParseInt(v, 10, 64) + } + + return intVal * mult, err +} + +// Lookup the last instance of a key and convert the value to an int64 +func (f *UnitFile) LookupInt(groupName string, key string, defaultValue int64) int64 { + v, ok := f.Lookup(groupName, key) + if !ok { + return defaultValue + } + + intVal, err := convertNumber(v) + + if err != nil { + return defaultValue + } + + return intVal +} + +// Lookup the last instance of a key and convert the value to an uint32 +func (f *UnitFile) LookupUint32(groupName string, key string, defaultValue uint32) uint32 { + v := f.LookupInt(groupName, key, int64(defaultValue)) + if v < 0 || v > math.MaxUint32 { + return defaultValue + } + return uint32(v) +} + +// Lookup the last instance of a key and convert a uid or a user name to an uint32 uid +func (f *UnitFile) LookupUID(groupName string, key string, defaultValue uint32) (uint32, error) { + v, ok := f.Lookup(groupName, key) + if !ok { + if defaultValue == math.MaxUint32 { + return 0, fmt.Errorf("no key %s", key) + } + return defaultValue, nil + } + + intVal, err := convertNumber(v) + if err == nil { + /* On linux, uids are uint32 values, that can't be (uint32)-1 (== MAXUINT32)*/ + if intVal < 0 || intVal >= math.MaxUint32 { + return 0, fmt.Errorf("invalid numerical uid '%s'", v) + } + + return uint32(intVal), nil + } + + user, err := user.Lookup(v) + if err != nil { + return 0, err + } + + intVal, err = strconv.ParseInt(user.Uid, 10, 64) + if err != nil { + return 0, err + } + + return uint32(intVal), nil +} + +// Lookup the last instance of a key and convert a uid or a group name to an uint32 gid +func (f *UnitFile) LookupGID(groupName string, key string, defaultValue uint32) (uint32, error) { + v, ok := f.Lookup(groupName, key) + if !ok { + if defaultValue == math.MaxUint32 { + return 0, fmt.Errorf("no key %s", key) + } + return defaultValue, nil + } + + intVal, err := convertNumber(v) + if err == nil { + /* On linux, uids are uint32 values, that can't be (uint32)-1 (== MAXUINT32)*/ + if intVal < 0 || intVal >= math.MaxUint32 { + return 0, fmt.Errorf("invalid numerical uid '%s'", v) + } + + return uint32(intVal), nil + } + + group, err := user.LookupGroup(v) + if err != nil { + return 0, err + } + + intVal, err = strconv.ParseInt(group.Gid, 10, 64) + if err != nil { + return 0, err + } + + return uint32(intVal), nil +} + +// Look up every instance of the named key in the group +// The result can have trailing whitespace, and Raw means it can +// contain line continuations (\ at end of line) +func (f *UnitFile) LookupAllRaw(groupName string, key string) []string { + g, ok := f.groupByName[groupName] + if !ok { + return make([]string, 0) + } + + values := make([]string, 0) + + for _, line := range g.lines { + if line.isKey(key) { + if len(line.value) == 0 { + // Empty value clears all before + values = make([]string, 0) + } else { + values = append(values, line.value) + } + } + } + + return values +} + +// Look up every instance of the named key in the group +// The result can have trailing whitespace, but line continuations are applied +func (f *UnitFile) LookupAll(groupName string, key string) []string { + values := f.LookupAllRaw(groupName, key) + for i, raw := range values { + values[i] = applyLineContinuation(raw) + } + return values +} + +// Look up every instance of the named key in the group, and for each, split space +// separated words (including handling quoted words) and combine them all into +// one array of words. The split code is compatible with the systemd config_parse_strv(). +// This is typically used by systemd keys like "RequiredBy" and "Aliases". +func (f *UnitFile) LookupAllStrv(groupName string, key string) []string { + res := make([]string, 0) + values := f.LookupAll(groupName, key) + for _, value := range values { + res, _ = splitStringAppend(res, value, WhitespaceSeparators, SplitRetainEscape|SplitUnquote) + } + return res +} + +// Look up every instance of the named key in the group, and for each, split space +// separated words (including handling quoted words) and combine them all into +// one array of words. The split code is exec-like, and both unquotes and applied +// c-style c escapes. +func (f *UnitFile) LookupAllArgs(groupName string, key string) []string { + res := make([]string, 0) + argsv := f.LookupAll(groupName, key) + for _, argsS := range argsv { + args, err := splitString(argsS, WhitespaceSeparators, SplitRelax|SplitUnquote|SplitCUnescape) + if err == nil { + res = append(res, args...) + } + } + return res +} + +// Look up last instance of the named key in the group, and split +// space separated words (including handling quoted words) into one +// array of words. The split code is exec-like, and both unquotes and +// applied c-style c escapes. This is typically used for keys like +// ExecStart +func (f *UnitFile) LookupLastArgs(groupName string, key string) ([]string, bool) { + execKey, ok := f.LookupLast(groupName, key) + if ok { + execArgs, err := splitString(execKey, WhitespaceSeparators, SplitRelax|SplitUnquote|SplitCUnescape) + if err == nil { + return execArgs, true + } + } + return nil, false +} + +// Look up 'Environment' style key-value keys +func (f *UnitFile) LookupAllKeyVal(groupName string, key string) map[string]string { + res := make(map[string]string) + allKeyvals := f.LookupAll(groupName, key) + for _, keyvals := range allKeyvals { + assigns, err := splitString(keyvals, WhitespaceSeparators, SplitRelax|SplitUnquote|SplitCUnescape) + if err == nil { + for _, assign := range assigns { + key, value, found := strings.Cut(assign, "=") + if found { + res[key] = value + } + } + } + } + return res +} + +func (f *UnitFile) Set(groupName string, key string, value string) { + group := f.ensureGroup(groupName) + group.set(key, value) +} + +func (f *UnitFile) Setv(groupName string, keyvals ...string) { + group := f.ensureGroup(groupName) + for i := 0; i+1 < len(keyvals); i += 2 { + group.set(keyvals[i], keyvals[i+1]) + } +} + +func (f *UnitFile) Add(groupName string, key string, value string) { + group := f.ensureGroup(groupName) + group.add(key, value) +} + +func (f *UnitFile) AddCmdline(groupName string, key string, args []string) { + f.Add(groupName, key, escapeWords(args)) +} + +func (f *UnitFile) Unset(groupName string, key string) { + group, ok := f.groupByName[groupName] + if ok { + group.unset(key) + } +} + +// Empty group name == first group +func (f *UnitFile) AddComment(groupName string, comments ...string) { + var group *unitGroup + if groupName == "" && len(f.groups) > 0 { + group = f.groups[0] + } else { + // Uses magic "" for first comment-only group if no other groups + group = f.ensureGroup(groupName) + } + + for _, comment := range comments { + group.addComment(newUnitLine("", "# "+comment, true)) + } +} + +func (f *UnitFile) PrependComment(groupName string, comments ...string) { + var group *unitGroup + if groupName == "" && len(f.groups) > 0 { + group = f.groups[0] + } else { + // Uses magic "" for first comment-only group if no other groups + group = f.ensureGroup(groupName) + } + // Prepend in reverse order to keep argument order + for i := len(comments) - 1; i >= 0; i-- { + group.prependComment(newUnitLine("", "# "+comments[i], true)) + } +} diff --git a/pkg/systemdparser/unitfile_test.go b/pkg/systemdparser/unitfile_test.go new file mode 100644 index 0000000000..308901b966 --- /dev/null +++ b/pkg/systemdparser/unitfile_test.go @@ -0,0 +1,245 @@ +package systemdparser + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const memcachedService = `# It's not recommended to modify this file in-place, because it will be +# overwritten during upgrades. If you want to customize, the best +# way is to use the "systemctl edit" command to create an override unit. +# +# For example, to pass additional options, create an override unit +# (as is done by systemctl edit) and enter the following: +# +# [Service] +# Environment=OPTIONS="-l 127.0.0.1,::1" + + +[Unit] +Description=memcached daemon +Before=httpd.service +After=network.target + +[Service] +EnvironmentFile=/etc/sysconfig/memcached +ExecStart=/usr/bin/memcached -p ${PORT} -u ${USER} -m ${CACHESIZE} -c ${MAXCONN} $OPTIONS + +# Set up a new file system namespace and mounts private /tmp and /var/tmp +# directories so this service cannot access the global directories and +# other processes cannot access this service's directories. +PrivateTmp=true + +# Mounts the /usr, /boot, and /etc directories read-only for processes +# invoked by this unit. +ProtectSystem=full + +# Ensures that the service process and all its children can never gain new +# privileges +NoNewPrivileges=true + +# Sets up a new /dev namespace for the executed processes and only adds API +# pseudo devices such as /dev/null, /dev/zero or /dev/random (as well as +# the pseudo TTY subsystem) to it, but no physical devices such as /dev/sda. +PrivateDevices=true + +# Required for dropping privileges and running as a different user +CapabilityBoundingSet=CAP_SETGID CAP_SETUID CAP_SYS_RESOURCE + +# Restricts the set of socket address families accessible to the processes +# of this unit. Protects against vulnerabilities such as CVE-2016-8655 +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX + + +# Some security features are not in the older versions of systemd used by +# e.g. RHEL7/CentOS 7. The below settings are automatically edited at package +# build time to uncomment them if the target platform supports them. + +# Attempts to create memory mappings that are writable and executable at +# the same time, or to change existing memory mappings to become executable +# are prohibited. +##safer##MemoryDenyWriteExecute=true + +# Explicit module loading will be denied. This allows to turn off module +# load and unload operations on modular kernels. It is recommended to turn +# this on for most services that do not need special file systems or extra +# kernel modules to work. +##safer##ProtectKernelModules=true + +# Kernel variables accessible through /proc/sys, /sys, /proc/sysrq-trigger, +# /proc/latency_stats, /proc/acpi, /proc/timer_stats, /proc/fs and /proc/irq +# will be made read-only to all processes of the unit. Usually, tunable +# kernel variables should only be written at boot-time, with the sysctl.d(5) +# mechanism. Almost no services need to write to these at runtime; it is hence +# recommended to turn this on for most services. +##safer##ProtectKernelTunables=true + +# The Linux Control Groups (cgroups(7)) hierarchies accessible through +# /sys/fs/cgroup will be made read-only to all processes of the unit. +# Except for container managers no services should require write access +# to the control groups hierarchies; it is hence recommended to turn this +# on for most services +##safer##ProtectControlGroups=true + +# Any attempts to enable realtime scheduling in a process of the unit are +# refused. +##safer##RestrictRealtime=true + +# Takes away the ability to create or manage any kind of namespace +##safer##RestrictNamespaces=true + +[Install] +WantedBy=multi-user.target +` + +const systemdloginService = `# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=User Login Management +Documentation=man:sd-login(3) +Documentation=man:systemd-logind.service(8) +Documentation=man:logind.conf(5) +Documentation=man:org.freedesktop.login1(5) + +Wants=user.slice modprobe@drm.service +After=nss-user-lookup.target user.slice modprobe@drm.service + +# Ask for the dbus socket. +Wants=dbus.socket +After=dbus.socket + +[Service] +BusName=org.freedesktop.login1 +CapabilityBoundingSet=CAP_SYS_ADMIN CAP_MAC_ADMIN CAP_AUDIT_CONTROL CAP_CHOWN CAP_DAC_READ_SEARCH CAP_DAC_OVERRIDE CAP_FOWNER CAP_SYS_TTY_CONFIG CAP_LINUX_IMMUTABLE +DeviceAllow=block-* r +DeviceAllow=char-/dev/console rw +DeviceAllow=char-drm rw +DeviceAllow=char-input rw +DeviceAllow=char-tty rw +DeviceAllow=char-vcs rw +ExecStart=/usr/lib/systemd/systemd-logind +FileDescriptorStoreMax=512 +IPAddressDeny=any +LockPersonality=yes +MemoryDenyWriteExecute=yes +NoNewPrivileges=yes +PrivateTmp=yes +ProtectProc=invisible +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectHostname=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectSystem=strict +ReadWritePaths=/etc /run +Restart=always +RestartSec=0 +RestrictAddressFamilies=AF_UNIX AF_NETLINK +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RuntimeDirectory=systemd/sessions systemd/seats systemd/users systemd/inhibit systemd/shutdown +RuntimeDirectoryPreserve=yes +StateDirectory=systemd/linger +SystemCallArchitectures=native +SystemCallErrorNumber=EPERM +SystemCallFilter=@system-service + + +# Increase the default a bit in order to allow many simultaneous logins since +# we keep one fd open per session. +LimitNOFILE=524288 +` +const systemdnetworkdService = `# SPDX-License-Identifier: LGPL-2.1-or-later +# +# This file is part of systemd. +# +# systemd is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 2.1 of the License, or +# (at your option) any later version. + +[Unit] +Description=Network Configuration +Documentation=man:systemd-networkd.service(8) +ConditionCapability=CAP_NET_ADMIN +DefaultDependencies=no +# systemd-udevd.service can be dropped once tuntap is moved to netlink +After=systemd-networkd.socket systemd-udevd.service network-pre.target systemd-sysusers.service systemd-sysctl.service +Before=network.target multi-user.target shutdown.target +Conflicts=shutdown.target +Wants=systemd-networkd.socket network.target + +[Service] +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_RAW +BusName=org.freedesktop.network1 +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_RAW +DeviceAllow=char-* rw +ExecStart=!!/usr/lib/systemd/systemd-networkd +ExecReload=networkctl reload +LockPersonality=yes +MemoryDenyWriteExecute=yes +NoNewPrivileges=yes +ProtectProc=invisible +ProtectClock=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectKernelLogs=yes +ProtectKernelModules=yes +ProtectSystem=strict +Restart=on-failure +RestartKillSignal=SIGUSR2 +RestartSec=0 +RestrictAddressFamilies=AF_UNIX AF_NETLINK AF_INET AF_INET6 AF_PACKET AF_ALG +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +RuntimeDirectory=systemd/netif +RuntimeDirectoryPreserve=yes +SystemCallArchitectures=native +SystemCallErrorNumber=EPERM +SystemCallFilter=@system-service +Type=notify +User=systemd-network + + +[Install] +WantedBy=multi-user.target +Also=systemd-networkd.socket +Alias=dbus-org.freedesktop.network1.service + +# We want to enable systemd-networkd-wait-online.service whenever this service +# is enabled. systemd-networkd-wait-online.service has +# WantedBy=network-online.target, so enabling it only has an effect if +# network-online.target itself is enabled or pulled in by some other unit. +Also=systemd-networkd-wait-online.service +` + +var samples = []string{memcachedService, systemdloginService, systemdnetworkdService} + +func TestRanges_Roundtrip(t *testing.T) { + for i := range samples { + sample := samples[i] + + f := NewUnitFile() + if e := f.Parse(sample); e != nil { + panic(e) + } + + asStr, e := f.ToString() + if e != nil { + panic(e) + } + + assert.Equal(t, sample, asStr) + } +} diff --git a/podman.spec.rpkg b/podman.spec.rpkg index eeaf49f639..994e9aff50 100644 --- a/podman.spec.rpkg +++ b/podman.spec.rpkg @@ -146,6 +146,18 @@ Remote client for managing %{name} containers. manage pods, containers and container images. %{name}-remote supports ssh connections as well. +%package quadlet +Summary: Easily create systemd services using %{name} +Requires: %{name} = %{epoch}:%{version}-%{release} +Requires(pre): /usr/sbin/useradd +Conflicts: quadlet + +%description quadlet +This package installs a systemd generator for *.container files in +/etc/containers/systemd. Such files are automatically converted into +systemd service units, allowing easily written and maintained +podman-based system services. + # The following four sections already describe the rpm build process itself. # prep will extract the tarball defined as Source above and descend into it. %prep @@ -186,6 +198,10 @@ export BUILDTAGS="$BASEBUILDTAGS $(hack/btrfs_installed_tag.sh) $(hack/btrfs_tag export BUILDTAGS="$BASEBUILDTAGS exclude_graphdriver_btrfs btrfs_noversion remote" %gobuild -o bin/%{name}-remote ./cmd/%{name} +# build quadlet +export BUILDTAGS="$BASEBUILDTAGS $(hack/btrfs_installed_tag.sh) $(hack/btrfs_tag.sh) -X $(PROJECT)/v4/pkg/quadlet.QuadletUserName=quadlet" +%gobuild -o bin/quadlet ./cmd/quadlet + make docs docker-docs # This will copy the files generated by the `make` command above into @@ -211,6 +227,17 @@ for file in `find %{buildroot}%{_mandir}/man[15] -type f | sed "s,%{buildroot},, echo "$file*" >> podman.file-list done +%pre quadlet +# We create a quadlet user so that we can get subuids and subgids allocated. +# It really is a system user, but Unfortunately useradd doesn't create subuids +# for system users, so we manually make it system-like and start at a higher +# min uid to avoid conflicts with common uid nrs around 1000 +getent passwd quadlet >/dev/null || \ + useradd -M -U -K SUB_UID_COUNT=65536 -K UID_MIN=50000 \ + -s /sbin/nologin -d /nonexisting \ + -c "User for quadlet" quadlet +exit 0 + # This lists all the files that are included in the rpm package and that # are going to be installed into target system where the rpm is installed. %files -f %{name}.file-list @@ -249,6 +276,12 @@ done %{_mandir}/man5/docker*.5* %{_usr}/lib/tmpfiles.d/%{name}-docker.conf +%files quadlet +%license LICENSE +%{_libexecdir}/%{name}/quadlet +%_prefix/lib/systemd/system-generators/podman-system-generator +%_prefix/lib/systemd/user-generators/podman-user-generator + %files remote %license LICENSE %{_bindir}/%{name}-remote diff --git a/test/e2e/common_test.go b/test/e2e/common_test.go index 67a889b25c..0ad972f3f6 100644 --- a/test/e2e/common_test.go +++ b/test/e2e/common_test.go @@ -47,6 +47,7 @@ var ( type PodmanTestIntegration struct { PodmanTest ConmonBinary string + QuadletBinary string Root string NetworkConfigDir string OCIRuntime string @@ -212,6 +213,11 @@ func PodmanTestCreateUtil(tempDir string, remote bool) *PodmanTestIntegration { podmanRemoteBinary = os.Getenv("PODMAN_REMOTE_BINARY") } + quadletBinary := filepath.Join(cwd, "../../bin/quadlet") + if os.Getenv("QUADLET_BINARY") != "" { + quadletBinary = os.Getenv("QUADLET_BINARY") + } + conmonBinary := "/usr/libexec/podman/conmon" altConmonBinary := "/usr/bin/conmon" if _, err := os.Stat(conmonBinary); os.IsNotExist(err) { @@ -280,6 +286,7 @@ func PodmanTestCreateUtil(tempDir string, remote bool) *PodmanTestIntegration { NetworkBackend: networkBackend, }, ConmonBinary: conmonBinary, + QuadletBinary: quadletBinary, Root: root, TmpDir: tempDir, NetworkConfigDir: networkConfigDir, @@ -513,6 +520,19 @@ func (p *PodmanTestIntegration) PodmanPID(args []string) (*PodmanSessionIntegrat return &PodmanSessionIntegration{podmanSession}, command.Process.Pid } +func (p *PodmanTestIntegration) Quadlet(args []string, sourceDir string) *PodmanSessionIntegration { + fmt.Printf("Running: %s %s with QUADLET_UNIT_DIRS=%s\n", p.QuadletBinary, strings.Join(args, " "), sourceDir) + + command := exec.Command(p.QuadletBinary, args...) + command.Env = []string{fmt.Sprintf("QUADLET_UNIT_DIRS=%s", sourceDir)} + session, err := Start(command, GinkgoWriter, GinkgoWriter) + if err != nil { + Fail("unable to run quadlet command: " + strings.Join(args, " ")) + } + quadletSession := &PodmanSession{Session: session} + return &PodmanSessionIntegration{quadletSession} +} + // Cleanup cleans up the temporary store func (p *PodmanTestIntegration) Cleanup() { // Remove all pods... diff --git a/test/e2e/quadlet/annotation.container b/test/e2e/quadlet/annotation.container new file mode 100644 index 0000000000..5038fa6679 --- /dev/null +++ b/test/e2e/quadlet/annotation.container @@ -0,0 +1,12 @@ +## assert-podman-final-args imagename +## assert-podman-args "--annotation" "org.foo.Arg0=arg0" +## assert-podman-args "--annotation" "org.foo.Arg1=arg1" +## assert-podman-args "--annotation" "org.foo.Arg2=arg 2" +## assert-podman-args "--annotation" "org.foo.Arg3=arg3" + +[Container] +Image=imagename +Annotation=org.foo.Arg1=arg1 "org.foo.Arg2=arg 2" \ + org.foo.Arg3=arg3 + +Annotation=org.foo.Arg0=arg0 diff --git a/test/e2e/quadlet/basepodman.container b/test/e2e/quadlet/basepodman.container new file mode 100644 index 0000000000..aa97351438 --- /dev/null +++ b/test/e2e/quadlet/basepodman.container @@ -0,0 +1,12 @@ +## assert-podman-final-args run --name=systemd-%N --cidfile=%t/%N.cid --replace --rm -d --log-driver journald --pull=never --runtime /usr/bin/crun --cgroups=split --sdnotify=conmon imagename + +[Container] +Image=imagename + +# Disable all default features to get as empty podman run command as we can +RemapUsers=no +NoNewPrivileges=no +DropCapability= +RunInit=no +VolatileTmp=no +Timezone= diff --git a/test/e2e/quadlet/basic.container b/test/e2e/quadlet/basic.container new file mode 100644 index 0000000000..ab770b10c7 --- /dev/null +++ b/test/e2e/quadlet/basic.container @@ -0,0 +1,27 @@ +## assert-podman-final-args imagename +## assert-podman-args "--name=systemd-%N" +## assert-podman-args "--cidfile=%t/%N.cid" +## assert-podman-args "--rm" +## assert-podman-args "--replace" +## assert-podman-args "-d" +## assert-podman-args "--log-driver" "journald" +## assert-podman-args "--pull=never" +## assert-podman-args "--init" +## assert-podman-args "--runtime" "/usr/bin/crun" +## assert-podman-args "--cgroups=split" +## assert-podman-args "--sdnotify=conmon" +## assert-podman-args "--security-opt=no-new-privileges" +## assert-podman-args "--cap-drop=all" +## assert-podman-args "--tmpfs" "/tmp:rw,size=512M,mode=1777" +## assert-key-is "Unit" "RequiresMountsFor" "%t/containers" +## assert-key-is "Service" "KillMode" "mixed" +## assert-key-is "Service" "Delegate" "yes" +## assert-key-is "Service" "Type" "notify" +## assert-key-is "Service" "NotifyAccess" "all" +## assert-key-is "Service" "SyslogIdentifier" "%N" +## assert-key-is "Service" "ExecStartPre" "-rm -f %t/%N.cid" +## assert-key-is "Service" "ExecStopPost" "-/usr/bin/podman rm -f -i --cidfile=%t/%N.cid" "-rm -f %t/%N.cid" +## assert-key-is "Service" "Environment" "PODMAN_SYSTEMD_UNIT=%n" + +[Container] +Image=imagename diff --git a/test/e2e/quadlet/basic.volume b/test/e2e/quadlet/basic.volume new file mode 100644 index 0000000000..ce8dbbed57 --- /dev/null +++ b/test/e2e/quadlet/basic.volume @@ -0,0 +1,8 @@ +## assert-key-is Unit RequiresMountsFor "%t/containers" +## assert-key-is Service Type oneshot +## assert-key-is Service RemainAfterExit yes +## assert-key-is Service ExecCondition '/usr/bin/bash -c "! /usr/bin/podman volume exists systemd-basic"' +## assert-key-is Service ExecStart "/usr/bin/podman volume create systemd-basic" +## assert-key-is Service SyslogIdentifier "%N" + +[Volume] diff --git a/test/e2e/quadlet/capabilities.container b/test/e2e/quadlet/capabilities.container new file mode 100644 index 0000000000..ec32ed0237 --- /dev/null +++ b/test/e2e/quadlet/capabilities.container @@ -0,0 +1,8 @@ +## assert-podman-args "--cap-drop=all" +## assert-podman-args "--cap-add=cap_dac_override" +## assert-podman-args "--cap-add=cap_ipc_owner" + +[Container] +Image=imagename +AddCapability=CAP_DAC_OVERRIDE +AddCapability=CAP_IPC_OWNER diff --git a/test/e2e/quadlet/env.container b/test/e2e/quadlet/env.container new file mode 100644 index 0000000000..26cbe5a2b9 --- /dev/null +++ b/test/e2e/quadlet/env.container @@ -0,0 +1,12 @@ +## assert-podman-final-args imagename +## assert-podman-args --env "FOO1=foo1" +## assert-podman-args --env "FOO2=foo2 " +## assert-podman-args --env "FOO3=foo3" +## assert-podman-args --env "REPLACE=replaced" +## assert-podman-args --env "FOO4=foo\\nfoo" + +[Container] +Image=imagename +Environment=FOO1=foo1 "FOO2=foo2 " \ + FOO3=foo3 REPLACE=replace +Environment=REPLACE=replaced 'FOO4=foo\nfoo' diff --git a/test/e2e/quadlet/escapes.container b/test/e2e/quadlet/escapes.container new file mode 100644 index 0000000000..051e4ed282 --- /dev/null +++ b/test/e2e/quadlet/escapes.container @@ -0,0 +1,5 @@ +## assert-podman-final-args "/some/path" "an arg" "a;b\\nc\\td'e" "a;b\\nc\\td" "a\"b" + +[Container] +Image=imagename +Exec=/some/path "an arg" "a;b\nc\td'e" a;b\nc\td 'a"b' diff --git a/test/e2e/quadlet/exec.container b/test/e2e/quadlet/exec.container new file mode 100644 index 0000000000..2b1a6f0f1a --- /dev/null +++ b/test/e2e/quadlet/exec.container @@ -0,0 +1,6 @@ +## assert-podman-final-args imagename "/some/binary file" "--arg1" "arg 2" + +[Container] +Image=imagename +Exec="/some/binary file" --arg1 \ + "arg 2" diff --git a/test/e2e/quadlet/image.container b/test/e2e/quadlet/image.container new file mode 100644 index 0000000000..1cc226d7e2 --- /dev/null +++ b/test/e2e/quadlet/image.container @@ -0,0 +1,4 @@ +## assert-podman-final-args imagename + +[Container] +Image=imagename diff --git a/test/e2e/quadlet/install.container b/test/e2e/quadlet/install.container new file mode 100644 index 0000000000..728f43fe43 --- /dev/null +++ b/test/e2e/quadlet/install.container @@ -0,0 +1,21 @@ +## assert-symlink alias.service install.service +## assert-symlink another-alias.service install.service +## assert-symlink in/a/dir/alias3.service ../../../install.service +## assert-symlink want1.service.wants/install.service ../install.service +## assert-symlink want2.service.wants/install.service ../install.service +## assert-symlink want3.service.wants/install.service ../install.service +## assert-symlink req1.service.requires/install.service ../install.service +## assert-symlink req2.service.requires/install.service ../install.service +## assert-symlink req3.service.requires/install.service ../install.service + +[Container] +Image=imagename + +[Install] +Alias=alias.service \ + "another-alias.service" +Alias=in/a/dir/alias3.service +WantedBy=want1.service want2.service +WantedBy=want3.service +RequiredBy=req1.service req2.service +RequiredBy=req3.service diff --git a/test/e2e/quadlet/label.container b/test/e2e/quadlet/label.container new file mode 100644 index 0000000000..753011ed0d --- /dev/null +++ b/test/e2e/quadlet/label.container @@ -0,0 +1,12 @@ +## assert-podman-final-args imagename +## assert-podman-args "--label" "org.foo.Arg0=arg0" +## assert-podman-args "--label" "org.foo.Arg1=arg1" +## assert-podman-args "--label" "org.foo.Arg2=arg 2" +## assert-podman-args "--label" "org.foo.Arg3=arg3" + +[Container] +Image=imagename +Label=org.foo.Arg1=arg1 "org.foo.Arg2=arg 2" \ + org.foo.Arg3=arg3 + +Label=org.foo.Arg0=arg0 diff --git a/test/e2e/quadlet/label.volume b/test/e2e/quadlet/label.volume new file mode 100644 index 0000000000..da6ee25209 --- /dev/null +++ b/test/e2e/quadlet/label.volume @@ -0,0 +1,8 @@ +## assert-key-contains Service ExecStart " --label org.foo.Arg1=arg1 " +## assert-key-contains Service ExecStart " --label org.foo.Arg2=arg2 " +## assert-key-contains Service ExecStart " --label org.foo.Arg3=arg3 " + +[Volume] +Label=org.foo.Arg1=arg1 +Label=org.foo.Arg2=arg2 \ + org.foo.Arg3=arg3 diff --git a/test/e2e/quadlet/name.container b/test/e2e/quadlet/name.container new file mode 100644 index 0000000000..efa6143406 --- /dev/null +++ b/test/e2e/quadlet/name.container @@ -0,0 +1,5 @@ +## assert-podman-args "--name=foobar" + +[Container] +Image=imagename +ContainerName=foobar diff --git a/test/e2e/quadlet/noimage.container b/test/e2e/quadlet/noimage.container new file mode 100644 index 0000000000..e55d07f604 --- /dev/null +++ b/test/e2e/quadlet/noimage.container @@ -0,0 +1,4 @@ +## assert-failed +## assert-stderr-contains "No Image key specified" + +[Container] diff --git a/test/e2e/quadlet/noremapuser.container b/test/e2e/quadlet/noremapuser.container new file mode 100644 index 0000000000..351393f121 --- /dev/null +++ b/test/e2e/quadlet/noremapuser.container @@ -0,0 +1,6 @@ +## !assert-podman-args --uidmap +## !assert-podman-args --gidmap + +[Container] +Image=imagename +RemapUsers=no diff --git a/test/e2e/quadlet/noremapuser2.container b/test/e2e/quadlet/noremapuser2.container new file mode 100644 index 0000000000..d3fe1cfd32 --- /dev/null +++ b/test/e2e/quadlet/noremapuser2.container @@ -0,0 +1,28 @@ +# This is an non-user-remapped container, but the user is mapped (uid +# 1000 in container is uid 90 on host). This means the result should +# map those particular ids to each other, but map all other container +# ids to the same as the host. + +# There is some additional complexity, as the host uid (90) that the +# container uid is mapped to can't also be mapped to itself, as ids +# can only be mapped once, so it has to be unmapped. + +## assert-podman-args --user 1000:1001 + +## assert-podman-args --uidmap 0:0:90 +## assert-podman-args --uidmap 91:91:909 +## assert-podman-args --uidmap 1000:90:1 +## assert-podman-args --uidmap 1001:1001:4294966294 + +## assert-podman-args --gidmap 0:0:91 +## assert-podman-args --gidmap 92:92:909 +## assert-podman-args --gidmap 1001:91:1 +## assert-podman-args --gidmap 1002:1002:4294966293 + +[Container] +Image=imagename +RemapUsers=no +User=1000 +Group=1001 +HostUser=90 +HostGroup=91 diff --git a/test/e2e/quadlet/notify.container b/test/e2e/quadlet/notify.container new file mode 100644 index 0000000000..9bc0fd3cc7 --- /dev/null +++ b/test/e2e/quadlet/notify.container @@ -0,0 +1,5 @@ +## assert-podman-args "--sdnotify=container" + +[Container] +Image=imagename +Notify=yes diff --git a/test/e2e/quadlet/other-sections.container b/test/e2e/quadlet/other-sections.container new file mode 100644 index 0000000000..4836e5f770 --- /dev/null +++ b/test/e2e/quadlet/other-sections.container @@ -0,0 +1,10 @@ +## assert-podman-final-args imagename +## assert-key-is "Unit" "Foo" "bar1" "bar2" +## assert-key-is "X-Container" "Image" "imagename" + +[Unit] +Foo=bar1 +Foo=bar2 + +[Container] +Image=imagename diff --git a/test/e2e/quadlet/podmanargs.container b/test/e2e/quadlet/podmanargs.container new file mode 100644 index 0000000000..6e97c16675 --- /dev/null +++ b/test/e2e/quadlet/podmanargs.container @@ -0,0 +1,9 @@ +## assert-podman-args "--foo" +## assert-podman-args "--bar" +## assert-podman-args "--also" + +[Container] +Image=imagename +PodmanArgs="--foo" \ + --bar +PodmanArgs=--also diff --git a/test/e2e/quadlet/ports.container b/test/e2e/quadlet/ports.container new file mode 100644 index 0000000000..195a6a6c54 --- /dev/null +++ b/test/e2e/quadlet/ports.container @@ -0,0 +1,54 @@ +[Container] +Image=imagename +## assert-podman-args --expose=1000 +ExposeHostPort=1000 +## assert-podman-args --expose=2000-3000 +ExposeHostPort=2000-3000 + +## assert-podman-args -p=127.0.0.1:80:90 +PublishPort=127.0.0.1:80:90 + +## assert-podman-args -p=80:91 +PublishPort=0.0.0.0:80:91 + +## assert-podman-args -p=80:92 +PublishPort=:80:92 + +## assert-podman-args -p=127.0.0.1::93 +PublishPort=127.0.0.1::93 + +## assert-podman-args -p=94 +PublishPort=0.0.0.0::94 + +## assert-podman-args -p=95 +PublishPort=::95 + +## assert-podman-args -p=80:96 +PublishPort=80:96 + +## assert-podman-args -p=97 +PublishPort=97 + +## assert-podman-args -p=1234/udp +PublishPort=1234/udp + +## assert-podman-args -p=1234:1234/udp +PublishPort=1234:1234/udp + +## assert-podman-args -p=127.0.0.1:1234:1234/udp +PublishPort=127.0.0.1:1234:1234/udp + +## assert-podman-args -p=1234/tcp +PublishPort=1234/tcp + +## assert-podman-args -p=1234:1234/tcp +PublishPort=1234:1234/tcp + +## assert-podman-args -p=127.0.0.1:1234:1234/tcp +PublishPort=127.0.0.1:1234:1234/tcp + +## assert-podman-args --expose=2000-3000/udp +ExposeHostPort=2000-3000/udp + +## assert-podman-args --expose=2000-3000/tcp +ExposeHostPort=2000-3000/tcp diff --git a/test/e2e/quadlet/ports_ipv6.container b/test/e2e/quadlet/ports_ipv6.container new file mode 100644 index 0000000000..f4ab421fad --- /dev/null +++ b/test/e2e/quadlet/ports_ipv6.container @@ -0,0 +1,28 @@ +[Container] +Image=imagename +## assert-podman-args -p=[::1]:80:90 +PublishPort=[::1]:80:90 + +## assert-podman-args -p=[::]:80:91 +PublishPort=[::]:80:91 + +## assert-podman-args -p=[2001:DB8::23]:80:91 +PublishPort=[2001:DB8::23]:80:91 + +## assert-podman-args -p=[::1]::93 +PublishPort=[::1]::93 + +## assert-podman-args -p=[::]::94 +PublishPort=[::]::94 + +## assert-podman-args -p=[2001:db8::42]::94 +PublishPort=[2001:db8::42]::94 + +## assert-podman-args -p=[::1]:1234:1234/udp +PublishPort=[::1]:1234:1234/udp + +## assert-podman-args -p=[::1]:1234:1234/tcp +PublishPort=[::1]:1234:1234/tcp + +## assert-podman-args -p=[2001:db8:c0:ff:ee::1]:1234:1234/udp +PublishPort=[2001:db8:c0:ff:ee::1]:1234:1234/udp diff --git a/test/e2e/quadlet/socketactivated.container b/test/e2e/quadlet/socketactivated.container new file mode 100644 index 0000000000..4b299ed79a --- /dev/null +++ b/test/e2e/quadlet/socketactivated.container @@ -0,0 +1,9 @@ +## assert-podman-args --preserve-fds=1 +## assert-podman-args --env LISTEN_FDS=1 +## assert-podman-args --env LISTEN_PID=2 +## assert-key-is "Service" "Type" "notify" +## assert-key-is "Service" "NotifyAccess" "all" + +[Container] +Image=imagename +SocketActivated=yes diff --git a/test/e2e/quadlet/timezone.container b/test/e2e/quadlet/timezone.container new file mode 100644 index 0000000000..4e52bb3a25 --- /dev/null +++ b/test/e2e/quadlet/timezone.container @@ -0,0 +1,5 @@ +## assert-podman-args --tz=foo + +[Container] +Image=imagename +Timezone=foo diff --git a/test/e2e/quadlet/uid.volume b/test/e2e/quadlet/uid.volume new file mode 100644 index 0000000000..f48badde36 --- /dev/null +++ b/test/e2e/quadlet/uid.volume @@ -0,0 +1,6 @@ +## assert-key-contains Service ExecStart " --opt o=uid=0,gid=11 " + +[Volume] +# Test usernames too +User=root +Group=11 diff --git a/test/e2e/quadlet/user-host.container b/test/e2e/quadlet/user-host.container new file mode 100644 index 0000000000..f61157024d --- /dev/null +++ b/test/e2e/quadlet/user-host.container @@ -0,0 +1,24 @@ +## assert-podman-args --user 1000:1001 + +## assert-podman-args --uidmap 0:0:1 +## assert-podman-args --uidmap 1:100000:999 +## assert-podman-args --uidmap 1000:900:1 +## assert-podman-args --uidmap 1001:100999:99001 + +## assert-podman-args --gidmap 0:0:1 +## assert-podman-args --gidmap 1:100000:1000 +## assert-podman-args --gidmap 1001:901:1 +## assert-podman-args --gidmap 1002:101000:99000 + +[Container] +Image=imagename +User=1000 +HostUser=900 +Group=1001 +HostGroup=901 + +RemapUsers=yes + +# Set this to get well-known valuse for the checks +RemapUidRanges=100000-199999 +RemapGidRanges=100000-199999 diff --git a/test/e2e/quadlet/user-root1.container b/test/e2e/quadlet/user-root1.container new file mode 100644 index 0000000000..7ddc7917e2 --- /dev/null +++ b/test/e2e/quadlet/user-root1.container @@ -0,0 +1,26 @@ +## assert-podman-args --user 1000:1001 + +## assert-podman-args --uidmap 0:100000:1000 +## assert-podman-args --uidmap 1000:0:1 +## assert-podman-args --uidmap 1001:101000:99000 +## !assert-podman-args --uidmap 0:0:1 + +## assert-podman-args --gidmap 0:100000:1001 +## assert-podman-args --gidmap 1001:0:1 +## assert-podman-args --gidmap 1002:101001:98999 +## !assert-podman-args --gidmap 0:0:1 + +# Map container uid 1000 to host root +# This means container root must map to something else + +[Container] +Image=imagename +User=1000 +# Also test name parsing +HostUser=root +Group=1001 +HostGroup=0 +RemapUsers=yes +# Set this to get well-known valuse for the checks +RemapUidRanges=100000-199999 +RemapGidRanges=100000-199999 diff --git a/test/e2e/quadlet/user-root2.container b/test/e2e/quadlet/user-root2.container new file mode 100644 index 0000000000..f3bc22ea25 --- /dev/null +++ b/test/e2e/quadlet/user-root2.container @@ -0,0 +1,22 @@ +# No need for --user 0:0, it is the default +## !assert-podman-args --user + +## assert-podman-args --uidmap 0:0:1 +## assert-podman-args --gidmap 0:0:1 + +## assert-podman-args --uidmap 1:100000:100000 +## assert-podman-args --gidmap 1:100000:100000 + +# Map container uid root to host root + +[Container] +Image=imagename +User=0 +# Also test name parsing +HostUser=root +Group=0 +HostGroup=0 +RemapUsers=yes +# Set this to get well-known valuse for the checks +RemapUidRanges=100000-199999 +RemapGidRanges=100000-199999 diff --git a/test/e2e/quadlet/user.container b/test/e2e/quadlet/user.container new file mode 100644 index 0000000000..ad1276a7c2 --- /dev/null +++ b/test/e2e/quadlet/user.container @@ -0,0 +1,7 @@ +## assert-podman-final-args imagename +## assert-podman-args "--user" "998:999" + +[Container] +Image=imagename +User=998 +Group=999 diff --git a/test/e2e/quadlet/volume.container b/test/e2e/quadlet/volume.container new file mode 100644 index 0000000000..85baafd0a4 --- /dev/null +++ b/test/e2e/quadlet/volume.container @@ -0,0 +1,12 @@ +## assert-podman-args -v /host/dir:/container/volume +## assert-podman-args -v /host/dir2:/container/volume2:Z +## assert-podman-args -v named:/container/named +## assert-podman-args -v systemd-quadlet:/container/quadlet imagename + +[Container] +Image=imagename +Volume=/host/dir:/container/volume +Volume=/host/dir2:/container/volume2:Z +Volume=/container/empty +Volume=named:/container/named +Volume=quadlet.volume:/container/quadlet diff --git a/test/e2e/quadlet_test.go b/test/e2e/quadlet_test.go new file mode 100644 index 0000000000..27e30849b1 --- /dev/null +++ b/test/e2e/quadlet_test.go @@ -0,0 +1,291 @@ +package integration + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/containers/podman/v4/pkg/systemdparser" + "github.com/mattn/go-shellwords" + + . "github.com/containers/podman/v4/test/utils" + . "github.com/onsi/ginkgo" + . "github.com/onsi/ginkgo/extensions/table" + . "github.com/onsi/gomega" + . "github.com/onsi/gomega/gexec" +) + +type quadletTestcase struct { + data []byte + serviceName string + checks [][]string +} + +func loadQuadletTestcase(path string) *quadletTestcase { + data, err := os.ReadFile(path) + Expect(err).To(BeNil()) + + base := filepath.Base(path) + ext := filepath.Ext(base) + service := base[:len(base)-len(ext)] + if ext == ".volume" { + service += "-volume" + } + service += ".service" + + checks := make([][]string, 0) + + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "##") { + words, err := shellwords.Parse(line[2:]) + Expect(err).To(BeNil()) + checks = append(checks, words) + } + } + + return &quadletTestcase{ + data, + service, + checks, + } +} + +func matchSublistAt(full []string, pos int, sublist []string) bool { + if len(sublist) > len(full)-pos { + return false + } + + for i := range sublist { + if sublist[i] != full[pos+i] { + return false + } + } + return true +} + +func findSublist(full []string, sublist []string) int { + if len(sublist) > len(full) { + return -1 + } + if len(sublist) == 0 { + return -1 + } + for i := 0; i < len(full)-len(sublist)+1; i++ { + if matchSublistAt(full, i, sublist) { + return i + } + } + return -1 +} + +func (t *quadletTestcase) assertStdErrContains(args []string, session *PodmanSessionIntegration) bool { + return strings.Contains(session.OutputToString(), args[0]) +} + +func (t *quadletTestcase) assertKeyIs(args []string, unit *systemdparser.UnitFile) bool { + group := args[0] + key := args[1] + values := args[2:] + + realValues := unit.LookupAll(group, key) + if len(realValues) != len(values) { + return false + } + + for i := range realValues { + if realValues[i] != values[i] { + return false + } + } + return true +} + +func (t *quadletTestcase) assertKeyContains(args []string, unit *systemdparser.UnitFile) bool { + group := args[0] + key := args[1] + value := args[2] + + realValue, ok := unit.LookupLast(group, key) + return ok && strings.Contains(realValue, value) +} + +func (t *quadletTestcase) assertPodmanArgs(args []string, unit *systemdparser.UnitFile) bool { + podmanArgs, _ := unit.LookupLastArgs("Service", "ExecStart") + return findSublist(podmanArgs, args) != -1 +} + +func (t *quadletTestcase) assertFinalArgs(args []string, unit *systemdparser.UnitFile) bool { + podmanArgs, _ := unit.LookupLastArgs("Service", "ExecStart") + if len(podmanArgs) < len(args) { + return false + } + return matchSublistAt(podmanArgs, len(podmanArgs)-len(args), args) +} + +func (t *quadletTestcase) assertSymlink(args []string, unit *systemdparser.UnitFile) bool { + symlink := args[0] + expectedTarget := args[1] + + dir := filepath.Dir(unit.Path) + + target, err := os.Readlink(filepath.Join(dir, symlink)) + Expect(err).ToNot(HaveOccurred()) + + return expectedTarget == target +} + +func (t *quadletTestcase) doAssert(check []string, unit *systemdparser.UnitFile, session *PodmanSessionIntegration) error { + op := check[0] + args := make([]string, 0) + for _, a := range check[1:] { + // Apply \n and \t as they are used in the testcases + a = strings.ReplaceAll(a, "\\n", "\n") + a = strings.ReplaceAll(a, "\\t", "\t") + args = append(args, a) + } + invert := false + if op[0] == '!' { + invert = true + op = op[1:] + } + + var ok bool + switch op { + case "assert-failed": + ok = true /* Handled separately */ + case "assert-stderr-contains": + ok = t.assertStdErrContains(args, session) + case "assert-key-is": + ok = t.assertKeyIs(args, unit) + case "assert-key-contains": + ok = t.assertKeyContains(args, unit) + case "assert-podman-args": + ok = t.assertPodmanArgs(args, unit) + case "assert-podman-final-args": + ok = t.assertFinalArgs(args, unit) + case "assert-symlink": + ok = t.assertSymlink(args, unit) + default: + return fmt.Errorf("Unsupported assertion %s", op) + } + if invert { + ok = !ok + } + + if !ok { + s, _ := unit.ToString() + return fmt.Errorf("Failed assertion for %s: %s\n\n%s", t.serviceName, strings.Join(check, " "), s) + } + return nil +} + +func (t *quadletTestcase) check(generateDir string, session *PodmanSessionIntegration) { + expectFail := false + for _, c := range t.checks { + if c[0] == "assert-failed" { + expectFail = true + } + } + + file := filepath.Join(generateDir, t.serviceName) + if _, err := os.Stat(file); os.IsNotExist(err) && expectFail { + return // Successful fail + } + + unit, err := systemdparser.ParseUnitFile(file) + Expect(err).To(BeNil()) + + for _, check := range t.checks { + err := t.doAssert(check, unit, session) + Expect(err).ToNot(HaveOccurred()) + } +} + +var _ = Describe("quadlet system generator", func() { + var ( + tempdir string + err error + generatedDir string + quadletDir string + podmanTest *PodmanTestIntegration + ) + + BeforeEach(func() { + tempdir, err = CreateTempDirInTempDir() + if err != nil { + os.Exit(1) + } + podmanTest = PodmanTestCreate(tempdir) + podmanTest.Setup() + + generatedDir = filepath.Join(podmanTest.TempDir, "generated") + err = os.Mkdir(generatedDir, os.ModePerm) + Expect(err).To(BeNil()) + + quadletDir = filepath.Join(podmanTest.TempDir, "quadlet") + err = os.Mkdir(quadletDir, os.ModePerm) + Expect(err).To(BeNil()) + }) + + AfterEach(func() { + podmanTest.Cleanup() + f := CurrentGinkgoTestDescription() + processTestResult(f) + + }) + + DescribeTable("Running quadlet test case", + func(fileName string) { + testcase := loadQuadletTestcase(filepath.Join("quadlet", fileName)) + + // Write the tested file to the quadlet dir + err = os.WriteFile(filepath.Join(quadletDir, fileName), testcase.data, 0644) + Expect(err).To(BeNil()) + + // Run quadlet to convert the file + session := podmanTest.Quadlet([]string{generatedDir}, quadletDir) + session.WaitWithDefaultTimeout() + Expect(session).Should(Exit(0)) + + // Print any stderr output + errs := session.ErrorToString() + if errs != "" { + fmt.Println("error:", session.ErrorToString()) + } + + testcase.check(generatedDir, session) + }, + Entry("Basic container", "basic.container"), + Entry("annotation.container", "annotation.container"), + Entry("basepodman.container", "basepodman.container"), + Entry("capabilities.container", "capabilities.container"), + Entry("env.container", "env.container"), + Entry("escapes.container", "escapes.container"), + Entry("exec.container", "exec.container"), + Entry("image.container", "image.container"), + Entry("install.container", "install.container"), + Entry("label.container", "label.container"), + Entry("name.container", "name.container"), + Entry("noimage.container", "noimage.container"), + Entry("noremapuser2.container", "noremapuser2.container"), + Entry("noremapuser.container", "noremapuser.container"), + Entry("notify.container", "notify.container"), + Entry("other-sections.container", "other-sections.container"), + Entry("podmanargs.container", "podmanargs.container"), + Entry("ports.container", "ports.container"), + Entry("ports_ipv6.container", "ports_ipv6.container"), + Entry("socketactivated.container", "socketactivated.container"), + Entry("timezone.container", "timezone.container"), + Entry("user.container", "user.container"), + Entry("user-host.container", "user-host.container"), + Entry("user-root1.container", "user-root1.container"), + Entry("user-root2.container", "user-root2.container"), + Entry("volume.container", "volume.container"), + + Entry("basic.volume", "basic.volume"), + Entry("label.volume", "label.volume"), + Entry("uid.volume", "uid.volume"), + ) + +})