Merge pull request #16035 from alexlarsson/quadlet

Initial quadlet version integrated in golang
This commit is contained in:
OpenShift Merge Robot
2022-10-17 15:13:39 -04:00
committed by GitHub
44 changed files with 4053 additions and 2 deletions

View File

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

273
cmd/quadlet/main.go Normal file
View File

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

2
go.mod
View File

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

View File

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

705
pkg/quadlet/quadlet.go Normal file
View File

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

View File

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

249
pkg/quadlet/ranges.go Normal file
View File

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

242
pkg/quadlet/ranges_test.go Normal file
View File

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

69
pkg/quadlet/subuids.go Normal file
View File

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

505
pkg/systemdparser/split.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
## assert-podman-final-args imagename
[Container]
Image=imagename

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
## assert-podman-args "--name=foobar"
[Container]
Image=imagename
ContainerName=foobar

View File

@ -0,0 +1,4 @@
## assert-failed
## assert-stderr-contains "No Image key specified"
[Container]

View File

@ -0,0 +1,6 @@
## !assert-podman-args --uidmap
## !assert-podman-args --gidmap
[Container]
Image=imagename
RemapUsers=no

View File

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

View File

@ -0,0 +1,5 @@
## assert-podman-args "--sdnotify=container"
[Container]
Image=imagename
Notify=yes

View File

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

View File

@ -0,0 +1,9 @@
## assert-podman-args "--foo"
## assert-podman-args "--bar"
## assert-podman-args "--also"
[Container]
Image=imagename
PodmanArgs="--foo" \
--bar
PodmanArgs=--also

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
## assert-podman-args --tz=foo
[Container]
Image=imagename
Timezone=foo

View File

@ -0,0 +1,6 @@
## assert-key-contains Service ExecStart " --opt o=uid=0,gid=11 "
[Volume]
# Test usernames too
User=root
Group=11

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
## assert-podman-final-args imagename
## assert-podman-args "--user" "998:999"
[Container]
Image=imagename
User=998
Group=999

View File

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

291
test/e2e/quadlet_test.go Normal file
View File

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