package quadlet import ( "bytes" "encoding/csv" "errors" "fmt" "os" "path/filepath" "strings" "github.com/containers/podman/v5/pkg/specgenutilexternal" "github.com/containers/podman/v5/pkg/systemd/parser" "github.com/containers/storage/pkg/fileutils" "github.com/containers/storage/pkg/regexp" ) const ( // Fixme should use // github.com/containers/podman/v5/libpod/define.AutoUpdateLabel // but it is causing bloat autoUpdateLabel = "io.containers.autoupdate" // Directory for temporary Quadlet files (sysadmin owned) UnitDirTemp = "/run/containers/systemd" // 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 ContainerGroup = "Container" InstallGroup = "Install" KubeGroup = "Kube" NetworkGroup = "Network" PodGroup = "Pod" ServiceGroup = "Service" UnitGroup = "Unit" VolumeGroup = "Volume" ImageGroup = "Image" BuildGroup = "Build" QuadletGroup = "Quadlet" XContainerGroup = "X-Container" XKubeGroup = "X-Kube" XNetworkGroup = "X-Network" XPodGroup = "X-Pod" XVolumeGroup = "X-Volume" XImageGroup = "X-Image" XBuildGroup = "X-Build" XQuadletGroup = "X-Quadlet" ) // Systemd Unit file keys const ( ServiceKeyWorkingDirectory = "WorkingDirectory" ) // All the supported quadlet keys const ( KeyAddCapability = "AddCapability" KeyAddDevice = "AddDevice" KeyAddHost = "AddHost" KeyAllTags = "AllTags" KeyAnnotation = "Annotation" KeyArch = "Arch" KeyAuthFile = "AuthFile" KeyAutoUpdate = "AutoUpdate" KeyCertDir = "CertDir" KeyCgroupsMode = "CgroupsMode" KeyConfigMap = "ConfigMap" KeyContainerName = "ContainerName" KeyContainersConfModule = "ContainersConfModule" KeyCopy = "Copy" KeyCreds = "Creds" KeyDecryptionKey = "DecryptionKey" KeyDefaultDependencies = "DefaultDependencies" KeyDevice = "Device" KeyDisableDNS = "DisableDNS" KeyDNS = "DNS" KeyDNSOption = "DNSOption" KeyDNSSearch = "DNSSearch" KeyDriver = "Driver" KeyDropCapability = "DropCapability" KeyEntrypoint = "Entrypoint" KeyEnvironment = "Environment" KeyEnvironmentFile = "EnvironmentFile" KeyEnvironmentHost = "EnvironmentHost" KeyExec = "Exec" KeyExitCodePropagation = "ExitCodePropagation" KeyExposeHostPort = "ExposeHostPort" KeyFile = "File" KeyForceRM = "ForceRM" KeyGateway = "Gateway" KeyGIDMap = "GIDMap" KeyGlobalArgs = "GlobalArgs" KeyGroup = "Group" KeyGroupAdd = "GroupAdd" KeyHealthCmd = "HealthCmd" KeyHealthInterval = "HealthInterval" KeyHealthLogDestination = "HealthLogDestination" KeyHealthMaxLogCount = "HealthMaxLogCount" KeyHealthMaxLogSize = "HealthMaxLogSize" KeyHealthOnFailure = "HealthOnFailure" KeyHealthRetries = "HealthRetries" KeyHealthStartPeriod = "HealthStartPeriod" KeyHealthStartupCmd = "HealthStartupCmd" KeyHealthStartupInterval = "HealthStartupInterval" KeyHealthStartupRetries = "HealthStartupRetries" KeyHealthStartupSuccess = "HealthStartupSuccess" KeyHealthStartupTimeout = "HealthStartupTimeout" KeyHealthTimeout = "HealthTimeout" KeyHostName = "HostName" KeyImage = "Image" KeyImageTag = "ImageTag" KeyInternal = "Internal" KeyIP = "IP" KeyIP6 = "IP6" KeyIPAMDriver = "IPAMDriver" KeyIPRange = "IPRange" KeyIPv6 = "IPv6" KeyKubeDownForce = "KubeDownForce" KeyLabel = "Label" KeyLogDriver = "LogDriver" KeyLogOpt = "LogOpt" KeyMask = "Mask" KeyMemory = "Memory" KeyMount = "Mount" KeyNetwork = "Network" KeyNetworkAlias = "NetworkAlias" KeyNetworkName = "NetworkName" KeyNoNewPrivileges = "NoNewPrivileges" KeyNotify = "Notify" KeyOptions = "Options" KeyOS = "OS" KeyPidsLimit = "PidsLimit" KeyPod = "Pod" KeyPodmanArgs = "PodmanArgs" KeyPodName = "PodName" KeyPublishPort = "PublishPort" KeyPull = "Pull" KeyReadOnly = "ReadOnly" KeyReadOnlyTmpfs = "ReadOnlyTmpfs" KeyRemapGid = "RemapGid" //nolint:stylecheck // deprecated KeyRemapUid = "RemapUid" //nolint:stylecheck // deprecated KeyRemapUidSize = "RemapUidSize" //nolint:stylecheck // deprecated KeyRemapUsers = "RemapUsers" // deprecated KeyRetry = "Retry" KeyRetryDelay = "RetryDelay" KeyRootfs = "Rootfs" KeyRunInit = "RunInit" KeySeccompProfile = "SeccompProfile" KeySecret = "Secret" KeySecurityLabelDisable = "SecurityLabelDisable" KeySecurityLabelFileType = "SecurityLabelFileType" KeySecurityLabelLevel = "SecurityLabelLevel" KeySecurityLabelNested = "SecurityLabelNested" KeySecurityLabelType = "SecurityLabelType" KeyServiceName = "ServiceName" KeySetWorkingDirectory = "SetWorkingDirectory" KeyShmSize = "ShmSize" KeyStartWithPod = "StartWithPod" KeyStopSignal = "StopSignal" KeyStopTimeout = "StopTimeout" KeySubGIDMap = "SubGIDMap" KeySubnet = "Subnet" KeySubUIDMap = "SubUIDMap" KeySysctl = "Sysctl" KeyTarget = "Target" KeyTimezone = "Timezone" KeyTLSVerify = "TLSVerify" KeyTmpfs = "Tmpfs" KeyType = "Type" KeyUIDMap = "UIDMap" KeyUlimit = "Ulimit" KeyUnmask = "Unmask" KeyUser = "User" KeyUserNS = "UserNS" KeyVariant = "Variant" KeyVolatileTmp = "VolatileTmp" // deprecated KeyVolume = "Volume" KeyVolumeName = "VolumeName" KeyWorkingDir = "WorkingDir" KeyYaml = "Yaml" ) type UnitInfo struct { // The name of the generated systemd service unit ServiceName string // The name of the podman resource created by the service ResourceName string // For .pod units // List of containers to start with the pod ContainersToStart []string } var ( URL = regexp.Delayed(`^((https?)|(git)://)|(github\.com/).+$`) validPortRange = regexp.Delayed(`\d+(-\d+)?(/udp|/tcp)?$`) // Supported keys in "Container" group supportedContainerKeys = map[string]bool{ KeyAddCapability: true, KeyAddDevice: true, KeyAddHost: true, KeyAnnotation: true, KeyAutoUpdate: true, KeyCgroupsMode: true, KeyContainerName: true, KeyContainersConfModule: true, KeyDNS: true, KeyDNSOption: true, KeyDNSSearch: true, KeyDropCapability: true, KeyEnvironment: true, KeyEnvironmentFile: true, KeyEnvironmentHost: true, KeyEntrypoint: true, KeyExec: true, KeyExposeHostPort: true, KeyGIDMap: true, KeyGlobalArgs: true, KeyGroup: true, KeyGroupAdd: true, KeyHealthCmd: true, KeyHealthInterval: true, KeyHealthOnFailure: true, KeyHealthLogDestination: true, KeyHealthMaxLogCount: true, KeyHealthMaxLogSize: true, KeyHealthRetries: true, KeyHealthStartPeriod: true, KeyHealthStartupCmd: true, KeyHealthStartupInterval: true, KeyHealthStartupRetries: true, KeyHealthStartupSuccess: true, KeyHealthStartupTimeout: true, KeyHealthTimeout: true, KeyHostName: true, KeyIP6: true, KeyIP: true, KeyImage: true, KeyLabel: true, KeyLogDriver: true, KeyLogOpt: true, KeyMask: true, KeyMemory: true, KeyMount: true, KeyNetwork: true, KeyNetworkAlias: true, KeyNoNewPrivileges: true, KeyNotify: true, KeyPidsLimit: true, KeyPod: true, KeyPodmanArgs: true, KeyPublishPort: true, KeyPull: true, KeyReadOnly: true, KeyReadOnlyTmpfs: true, KeyRemapGid: true, KeyRemapUid: true, KeyRemapUidSize: true, KeyRemapUsers: true, KeyRetry: true, KeyRetryDelay: true, KeyRootfs: true, KeyRunInit: true, KeySeccompProfile: true, KeySecret: true, KeySecurityLabelDisable: true, KeySecurityLabelFileType: true, KeySecurityLabelLevel: true, KeySecurityLabelNested: true, KeySecurityLabelType: true, KeyServiceName: true, KeyShmSize: true, KeyStopSignal: true, KeyStartWithPod: true, KeyStopTimeout: true, KeySubGIDMap: true, KeySubUIDMap: true, KeySysctl: true, KeyTimezone: true, KeyTmpfs: true, KeyUIDMap: true, KeyUlimit: true, KeyUnmask: true, KeyUser: true, KeyUserNS: true, KeyVolatileTmp: true, KeyVolume: true, KeyWorkingDir: true, } // Supported keys in "Volume" group supportedVolumeKeys = map[string]bool{ KeyContainersConfModule: true, KeyCopy: true, KeyDevice: true, KeyDriver: true, KeyGlobalArgs: true, KeyGroup: true, KeyImage: true, KeyLabel: true, KeyOptions: true, KeyPodmanArgs: true, KeyServiceName: true, KeyType: true, KeyUser: true, KeyVolumeName: true, } // Supported keys in "Network" group supportedNetworkKeys = map[string]bool{ KeyLabel: true, KeyDNS: true, KeyContainersConfModule: true, KeyGlobalArgs: true, KeyDisableDNS: true, KeyDriver: true, KeyGateway: true, KeyIPAMDriver: true, KeyIPRange: true, KeyIPv6: true, KeyInternal: true, KeyNetworkName: true, KeyOptions: true, KeyServiceName: true, KeySubnet: true, KeyPodmanArgs: true, } // Supported keys in "Kube" group supportedKubeKeys = map[string]bool{ KeyAutoUpdate: true, KeyConfigMap: true, KeyContainersConfModule: true, KeyExitCodePropagation: true, KeyGlobalArgs: true, KeyKubeDownForce: true, KeyLogDriver: true, KeyLogOpt: true, KeyNetwork: true, KeyPodmanArgs: true, KeyPublishPort: true, KeyRemapGid: true, KeyRemapUid: true, KeyRemapUidSize: true, KeyRemapUsers: true, KeyServiceName: true, KeySetWorkingDirectory: true, KeyUserNS: true, KeyYaml: true, } // Supported keys in "Image" group supportedImageKeys = map[string]bool{ KeyAllTags: true, KeyArch: true, KeyAuthFile: true, KeyCertDir: true, KeyContainersConfModule: true, KeyCreds: true, KeyDecryptionKey: true, KeyGlobalArgs: true, KeyImage: true, KeyImageTag: true, KeyOS: true, KeyPodmanArgs: true, KeyRetry: true, KeyRetryDelay: true, KeyServiceName: true, KeyTLSVerify: true, KeyVariant: true, } // Supported keys in "Build" group supportedBuildKeys = map[string]bool{ KeyAnnotation: true, KeyArch: true, KeyAuthFile: true, KeyContainersConfModule: true, KeyDNS: true, KeyDNSOption: true, KeyDNSSearch: true, KeyEnvironment: true, KeyFile: true, KeyForceRM: true, KeyGlobalArgs: true, KeyGroupAdd: true, KeyImageTag: true, KeyLabel: true, KeyNetwork: true, KeyPodmanArgs: true, KeyPull: true, KeyRetry: true, KeyRetryDelay: true, KeySecret: true, KeyServiceName: true, KeySetWorkingDirectory: true, KeyTarget: true, KeyTLSVerify: true, KeyVariant: true, KeyVolume: true, } supportedPodKeys = map[string]bool{ KeyAddHost: true, KeyContainersConfModule: true, KeyDNS: true, KeyDNSOption: true, KeyDNSSearch: true, KeyGIDMap: true, KeyGlobalArgs: true, KeyIP: true, KeyIP6: true, KeyNetwork: true, KeyNetworkAlias: true, KeyPodName: true, KeyPodmanArgs: true, KeyPublishPort: true, KeyRemapGid: true, KeyRemapUid: true, KeyRemapUidSize: true, KeyRemapUsers: true, KeyServiceName: true, KeyShmSize: true, KeySubGIDMap: true, KeySubUIDMap: true, KeyUIDMap: true, KeyUserNS: true, KeyVolume: true, } // Supported keys in "Quadlet" group supportedQuadletKeys = map[string]bool{ KeyDefaultDependencies: true, } ) func (u *UnitInfo) ServiceFileName() string { return fmt.Sprintf("%s.service", u.ServiceName) } func removeExtension(name string, extraPrefix string, extraSuffix string) string { baseName := name dot := strings.LastIndexByte(name, '.') if dot > 0 { baseName = name[:dot] } return extraPrefix + baseName + extraSuffix } func isURL(urlCandidate string) bool { return URL.MatchString(urlCandidate) } func isPortRange(port string) bool { return validPortRange.MatchString(port) } func checkForUnknownKeysInSpecificGroup(unit *parser.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 checkForUnknownKeys(unit *parser.UnitFile, groupName string, supportedKeys map[string]bool) error { err := checkForUnknownKeysInSpecificGroup(unit, groupName, supportedKeys) if err == nil { return checkForUnknownKeysInSpecificGroup(unit, QuadletGroup, supportedQuadletKeys) } return err } 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 usernsOpts(kind string, opts []string) string { var res strings.Builder res.WriteString(kind) if len(opts) > 0 { res.WriteString(":") } for i, opt := range opts { if i != 0 { res.WriteString(",") } res.WriteString(opt) } return res.String() } // 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 *parser.UnitFile, isUser bool, unitsInfoMap map[string]*UnitInfo) (*parser.UnitFile, error, error) { var warn, warnings error unitInfo, ok := unitsInfoMap[container.Filename] if !ok { return nil, warnings, fmt.Errorf("internal error while processing container %s", container.Filename) } service := container.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if container.Path != "" { service.Add(UnitGroup, "SourcePath", container.Path) } if err := checkForUnknownKeys(container, ContainerGroup, supportedContainerKeys); err != nil { return nil, warnings, err } // Rename old Container group to x-Container so that systemd ignores it service.RenameGroup(ContainerGroup, XContainerGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // One image or rootfs must be specified for the container image, _ := container.Lookup(ContainerGroup, KeyImage) rootfs, _ := container.Lookup(ContainerGroup, KeyRootfs) if len(image) == 0 && len(rootfs) == 0 { return nil, warnings, fmt.Errorf("no Image or Rootfs key specified") } if len(image) > 0 && len(rootfs) > 0 { return nil, warnings, fmt.Errorf("the Image And Rootfs keys conflict can not be specified together") } if len(image) > 0 { var err error if image, err = handleImageSource(image, service, unitsInfoMap); err != nil { return nil, warnings, err } } containerName := getContainerName(container) // 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, warnings, 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, warn := container.LookupAllKeyVal(ContainerGroup, KeyEnvironment) warnings = errors.Join(warnings, warn) // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") // If conmon exited uncleanly it may not have removed the container, so // force it, -i makes it ignore non-existing files. serviceStopCmd := createBasePodmanCommand(container, ContainerGroup) serviceStopCmd.add("rm", "-v", "-f", "-i", "--cidfile=%t/%N.cid") service.AddCmdline(ServiceGroup, "ExecStop", serviceStopCmd.Args) // The ExecStopPost is needed when the main PID (i.e., conmon) gets killed. // In that case, ExecStop is not executed but *Post only. If both are // fired in sequence, *Post will exit when detecting that the --cidfile // has already been removed by the previous `rm`.. serviceStopCmd.Args[0] = fmt.Sprintf("-%s", serviceStopCmd.Args[0]) service.AddCmdline(ServiceGroup, "ExecStopPost", serviceStopCmd.Args) podman := createBasePodmanCommand(container, ContainerGroup) podman.add("run") podman.add("--name", 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", ) handleLogDriver(container, ContainerGroup, podman) handleLogOpt(container, ContainerGroup, podman) // We delegate groups to the runtime service.Add(ServiceGroup, "Delegate", "yes") if cgroupsMode, ok := container.Lookup(ContainerGroup, KeyCgroupsMode); ok && len(cgroupsMode) > 0 { podman.add("--cgroups", cgroupsMode) } else { podman.add("--cgroups=split") } stringKeys := map[string]string{ KeyTimezone: "--tz", KeyPidsLimit: "--pids-limit", KeyShmSize: "--shm-size", KeyEntrypoint: "--entrypoint", KeyWorkingDir: "--workdir", KeyIP: "--ip", KeyIP6: "--ip6", KeyHostName: "--hostname", KeyStopSignal: "--stop-signal", KeyStopTimeout: "--stop-timeout", KeyPull: "--pull", KeyMemory: "--memory", KeyRetry: "--retry", KeyRetryDelay: "--retry-delay", } lookupAndAddString(container, ContainerGroup, stringKeys, podman) allStringsKeys := map[string]string{ KeyNetworkAlias: "--network-alias", KeyUlimit: "--ulimit", KeyDNS: "--dns", KeyDNSOption: "--dns-option", KeyDNSSearch: "--dns-search", KeyGroupAdd: "--group-add", KeyAddHost: "--add-host", KeyTmpfs: "--tmpfs", } lookupAndAddAllStrings(container, ContainerGroup, allStringsKeys, podman) boolKeys := map[string]string{ KeyRunInit: "--init", KeyEnvironmentHost: "--env-host", KeyReadOnlyTmpfs: "--read-only-tmpfs", } lookupAndAddBoolean(container, ContainerGroup, boolKeys, podman) if err := addNetworks(container, ContainerGroup, service, unitsInfoMap, podman); err != nil { return nil, warnings, err } serviceType, ok := service.Lookup(ServiceGroup, "Type") if ok && serviceType != "notify" && serviceType != "oneshot" { return nil, warnings, fmt.Errorf("invalid service Type '%s'", serviceType) } if serviceType != "oneshot" { // If we're not in oneshot mode always use some form of sd-notify, normally via conmon, // but we also allow passing it to the container by setting Notify=yes notify, ok := container.Lookup(ContainerGroup, KeyNotify) switch { case ok && strings.EqualFold(notify, "healthy"): podman.add("--sdnotify=healthy") case container.LookupBooleanWithDefault(ContainerGroup, KeyNotify, false): podman.add("--sdnotify=container") default: podman.add("--sdnotify=conmon") } service.Setv(ServiceGroup, "Type", "notify", "NotifyAccess", "all") // Detach from container, we don't need the podman process to hang around podman.add("-d") } if !container.HasKey(ServiceGroup, "SyslogIdentifier") { service.Set(ServiceGroup, "SyslogIdentifier", "%N") } // Default to no higher level privileges or caps noNewPrivileges := container.LookupBooleanWithDefault(ContainerGroup, KeyNoNewPrivileges, false) if noNewPrivileges { podman.add("--security-opt=no-new-privileges") } securityLabelDisable := container.LookupBooleanWithDefault(ContainerGroup, KeySecurityLabelDisable, false) if securityLabelDisable { podman.add("--security-opt", "label=disable") } securityLabelNested := container.LookupBooleanWithDefault(ContainerGroup, KeySecurityLabelNested, false) if securityLabelNested { podman.add("--security-opt", "label=nested") } securityLabelType, ok := container.Lookup(ContainerGroup, KeySecurityLabelType) if ok && len(securityLabelType) > 0 { podman.add("--security-opt", fmt.Sprintf("label=type:%s", securityLabelType)) } securityLabelFileType, ok := container.Lookup(ContainerGroup, KeySecurityLabelFileType) if ok && len(securityLabelFileType) > 0 { podman.add("--security-opt", fmt.Sprintf("label=filetype:%s", securityLabelFileType)) } securityLabelLevel, ok := container.Lookup(ContainerGroup, KeySecurityLabelLevel) if ok && len(securityLabelLevel) > 0 { podman.add("--security-opt", fmt.Sprintf("label=level:%s", securityLabelLevel)) } devices := container.LookupAllStrv(ContainerGroup, KeyAddDevice) for _, device := range devices { if device[0] == '-' { device = device[1:] err := fileutils.Exists(strings.Split(device, ":")[0]) if errors.Is(err, os.ErrNotExist) { continue } } podman.add("--device", device) } // Default to no higher level privileges or caps seccompProfile, hasSeccompProfile := container.Lookup(ContainerGroup, KeySeccompProfile) if hasSeccompProfile { podman.add("--security-opt", fmt.Sprintf("seccomp=%s", seccompProfile)) } dropCaps := container.LookupAllStrv(ContainerGroup, KeyDropCapability) for _, caps := range dropCaps { podman.add("--cap-drop", strings.ToLower(caps)) } // But allow overrides with AddCapability addCaps := container.LookupAllStrv(ContainerGroup, KeyAddCapability) for _, caps := range addCaps { podman.add("--cap-add", strings.ToLower(caps)) } sysctl := container.LookupAllStrv(ContainerGroup, KeySysctl) for _, sysctlItem := range sysctl { podman.add("--sysctl", sysctlItem) } // This was not moved to the generic handling since readOnly is used also with volatileTmp readOnly, ok := container.LookupBoolean(ContainerGroup, KeyReadOnly) if ok { podman.addBool("--read-only", readOnly) } volatileTmp := container.LookupBooleanWithDefault(ContainerGroup, KeyVolatileTmp, false) if volatileTmp && !readOnly { podman.add("--tmpfs", "/tmp:rw,size=512M,mode=1777") } if err := handleUser(container, ContainerGroup, podman); err != nil { return nil, warnings, err } if err := handleUserMappings(container, ContainerGroup, podman, true); err != nil { return nil, warnings, err } if err := addVolumes(container, service, ContainerGroup, unitsInfoMap, podman); err != nil { return nil, warnings, err } update, ok := container.Lookup(ContainerGroup, KeyAutoUpdate) if ok && len(update) > 0 { podman.addLabels(map[string]string{ autoUpdateLabel: update, }) } exposedPorts := container.LookupAll(ContainerGroup, KeyExposeHostPort) for _, exposedPort := range exposedPorts { exposedPort = strings.TrimSpace(exposedPort) // Allow whitespace after if !isPortRange(exposedPort) { return nil, warnings, fmt.Errorf("invalid port format '%s'", exposedPort) } podman.add("--expose", exposedPort) } handlePublishPorts(container, ContainerGroup, podman) podman.addEnv(podmanEnv) labels, warn := container.LookupAllKeyVal(ContainerGroup, KeyLabel) warnings = errors.Join(warnings, warn) podman.addLabels(labels) annotations, warn := container.LookupAllKeyVal(ContainerGroup, KeyAnnotation) warnings = errors.Join(warnings, warn) podman.addAnnotations(annotations) masks := container.LookupAllArgs(ContainerGroup, KeyMask) for _, mask := range masks { podman.add("--security-opt", fmt.Sprintf("mask=%s", mask)) } unmasks := container.LookupAllArgs(ContainerGroup, KeyUnmask) for _, unmask := range unmasks { podman.add("--security-opt", fmt.Sprintf("unmask=%s", unmask)) } envFiles := container.LookupAllArgs(ContainerGroup, KeyEnvironmentFile) for _, envFile := range envFiles { filePath, err := getAbsolutePath(container, envFile) if err != nil { return nil, warnings, err } podman.add("--env-file", filePath) } secrets := container.LookupAllArgs(ContainerGroup, KeySecret) for _, secret := range secrets { podman.add("--secret", secret) } mounts := container.LookupAllArgs(ContainerGroup, KeyMount) for _, mount := range mounts { mountStr, err := resolveContainerMountParams(container, service, mount, unitsInfoMap) if err != nil { return nil, warnings, err } podman.add("--mount", mountStr) } handleHealth(container, ContainerGroup, podman) if err := handlePod(container, service, ContainerGroup, unitsInfoMap, podman); err != nil { return nil, warnings, err } handlePodmanArgs(container, ContainerGroup, podman) if len(image) > 0 { podman.add(image) } else { podman.add("--rootfs", rootfs) } execArgs, ok := container.LookupLastArgs(ContainerGroup, KeyExec) if ok { podman.add(execArgs...) } service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) return service, warnings, nil } // Get the unresolved container name that may contain '%'. func getContainerName(container *parser.UnitFile) string { containerName, ok := container.Lookup(ContainerGroup, KeyContainerName) if !ok || len(containerName) == 0 { // By default, We want to name the container by the service name. if strings.Contains(container.Filename, "@") { containerName = "systemd-%p_%i" } else { containerName = "systemd-%N" } } return containerName } // Get the resolved container name that contains no '%'. // Returns an empty string if not resolvable. func GetContainerResourceName(container *parser.UnitFile) string { containerName := getContainerName(container) // XXX: only %N is handled. // it is difficult to properly implement specifiers handling without consulting systemd. resourceName := strings.ReplaceAll(containerName, "%N", GetContainerServiceName(container)) if !strings.Contains(resourceName, "%") { return resourceName } else { return "" } } func defaultOneshotServiceGroup(service *parser.UnitFile, remainAfterExit bool) { // The default syslog identifier is the exec basename (podman) which isn't very useful here if _, ok := service.Lookup(ServiceGroup, "SyslogIdentifier"); !ok { service.Set(ServiceGroup, "SyslogIdentifier", "%N") } if _, ok := service.Lookup(ServiceGroup, "Type"); !ok { service.Set(ServiceGroup, "Type", "oneshot") } if remainAfterExit { if _, ok := service.Lookup(ServiceGroup, "RemainAfterExit"); !ok { service.Set(ServiceGroup, "RemainAfterExit", "yes") } } } // Convert a quadlet network file (unit file with a Network group) to a systemd // service file (unit file with Service group) based on the options in the // Network group. // The original Network group is kept around as X-Network. // Also returns the canonical network name, either auto-generated or user-defined via the // NetworkName key-value. func ConvertNetwork(network *parser.UnitFile, name string, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error, error) { var warn, warnings error unitInfo, ok := unitsInfoMap[network.Filename] if !ok { return nil, warnings, fmt.Errorf("internal error while processing network %s", network.Filename) } service := network.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if network.Path != "" { service.Add(UnitGroup, "SourcePath", network.Path) } if err := checkForUnknownKeys(network, NetworkGroup, supportedNetworkKeys); err != nil { return nil, warnings, err } /* Rename old Network group to x-Network so that systemd ignores it */ service.RenameGroup(NetworkGroup, XNetworkGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // Derive network name from unit name (with added prefix), or use user-provided name. networkName, ok := network.Lookup(NetworkGroup, KeyNetworkName) if !ok || len(networkName) == 0 { networkName = removeExtension(name, "systemd-", "") } // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") podman := createBasePodmanCommand(network, NetworkGroup) podman.add("network", "create", "--ignore") boolKeys := map[string]string{ KeyDisableDNS: "--disable-dns", KeyInternal: "--internal", KeyIPv6: "--ipv6", } lookupAndAddBoolean(network, NetworkGroup, boolKeys, podman) stringKeys := map[string]string{ KeyDriver: "--driver", KeyIPAMDriver: "--ipam-driver", } lookupAndAddString(network, NetworkGroup, stringKeys, podman) allStringKeys := map[string]string{ KeyDNS: "--dns", } lookupAndAddAllStrings(network, NetworkGroup, allStringKeys, podman) subnets := network.LookupAll(NetworkGroup, KeySubnet) gateways := network.LookupAll(NetworkGroup, KeyGateway) ipRanges := network.LookupAll(NetworkGroup, KeyIPRange) if len(subnets) > 0 { if len(gateways) > len(subnets) { return nil, warnings, fmt.Errorf("cannot set more gateways than subnets") } if len(ipRanges) > len(subnets) { return nil, warnings, fmt.Errorf("cannot set more ranges than subnets") } for i := range subnets { podman.add("--subnet", subnets[i]) if len(gateways) > i { podman.add("--gateway", gateways[i]) } if len(ipRanges) > i { podman.add("--ip-range", ipRanges[i]) } } } else if len(ipRanges) > 0 || len(gateways) > 0 { return nil, warnings, fmt.Errorf("cannot set gateway or range without subnet") } networkOptions, warn := network.LookupAllKeyVal(NetworkGroup, KeyOptions) warnings = errors.Join(warnings, warn) if len(networkOptions) > 0 { podman.addKeys("--opt", networkOptions) } labels, warn := network.LookupAllKeyVal(NetworkGroup, KeyLabel) warnings = errors.Join(warnings, warn) if len(labels) > 0 { podman.addLabels(labels) } handlePodmanArgs(network, NetworkGroup, podman) podman.add(networkName) service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) defaultOneshotServiceGroup(service, true) // Store the name of the created resource unitInfo.ResourceName = networkName return service, warnings, 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 Volume group is kept around as X-Volume. // Also returns the canonical volume name, either auto-generated or user-defined via the VolumeName // key-value. func ConvertVolume(volume *parser.UnitFile, name string, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error, error) { var warn, warnings error unitInfo, ok := unitsInfoMap[volume.Filename] if !ok { return nil, warnings, fmt.Errorf("internal error while processing network %s", volume.Filename) } service := volume.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if volume.Path != "" { service.Add(UnitGroup, "SourcePath", volume.Path) } if err := checkForUnknownKeys(volume, VolumeGroup, supportedVolumeKeys); err != nil { return nil, warnings, err } /* Rename old Volume group to x-Volume so that systemd ignores it */ service.RenameGroup(VolumeGroup, XVolumeGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // Derive volume name from unit name (with added prefix), or use user-provided name. volumeName, ok := volume.Lookup(VolumeGroup, KeyVolumeName) if !ok || len(volumeName) == 0 { volumeName = removeExtension(name, "systemd-", "") } // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") labels, warn := volume.LookupAllKeyVal(VolumeGroup, "Label") warnings = errors.Join(warnings, warn) podman := createBasePodmanCommand(volume, VolumeGroup) podman.add("volume", "create", "--ignore") driver, ok := volume.Lookup(VolumeGroup, KeyDriver) if ok { podman.add("--driver", driver) } var opts strings.Builder if driver == "image" { opts.WriteString("image=") imageName, ok := volume.Lookup(VolumeGroup, KeyImage) if !ok { return nil, warnings, fmt.Errorf("the key %s is mandatory when using the image driver", KeyImage) } imageName, err := handleImageSource(imageName, service, unitsInfoMap) if err != nil { return nil, warnings, err } opts.WriteString(imageName) } else { 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)) } copy, ok := volume.LookupBoolean(VolumeGroup, KeyCopy) if ok { if copy { podman.add("--opt", "copy") } else { podman.add("--opt", "nocopy") } } devValid := false dev, ok := volume.Lookup(VolumeGroup, KeyDevice) if ok && len(dev) != 0 { podman.add("--opt", fmt.Sprintf("device=%s", dev)) devValid = true } devType, ok := volume.Lookup(VolumeGroup, KeyType) if ok && len(devType) != 0 { if devValid { podman.add("--opt", fmt.Sprintf("type=%s", devType)) } else { return nil, warnings, fmt.Errorf("key Type can't be used without Device") } } mountOpts, ok := volume.Lookup(VolumeGroup, KeyOptions) if ok && len(mountOpts) != 0 { if devValid { if opts.Len() > 2 { opts.WriteString(",") } opts.WriteString(mountOpts) } else { return nil, warnings, fmt.Errorf("key Options can't be used without Device") } } } if opts.Len() > 2 { podman.add("--opt", opts.String()) } podman.addLabels(labels) handlePodmanArgs(volume, VolumeGroup, podman) podman.add(volumeName) service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) defaultOneshotServiceGroup(service, true) // Store the name of the created resource unitInfo.ResourceName = volumeName return service, warnings, nil } func ConvertKube(kube *parser.UnitFile, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error) { unitInfo, ok := unitsInfoMap[kube.Filename] if !ok { return nil, fmt.Errorf("internal error while processing network %s", kube.Filename) } service := kube.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if kube.Path != "" { service.Add(UnitGroup, "SourcePath", kube.Path) } if err := checkForUnknownKeys(kube, KubeGroup, supportedKubeKeys); err != nil { return nil, err } // Rename old Kube group to x-Kube so that systemd ignores it service.RenameGroup(KubeGroup, XKubeGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) yamlPath, ok := kube.Lookup(KubeGroup, KeyYaml) if !ok || len(yamlPath) == 0 { return nil, fmt.Errorf("no Yaml key specified") } yamlPath, err := getAbsolutePath(kube, yamlPath) if err != nil { return nil, err } // 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") } // Set PODMAN_SYSTEMD_UNIT so that podman auto-update can restart the service. service.Add(ServiceGroup, "Environment", "PODMAN_SYSTEMD_UNIT=%n") // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") // Allow users to set the Service Type to oneshot to allow resources only kube yaml serviceType, ok := service.Lookup(ServiceGroup, "Type") if ok && serviceType != "notify" && serviceType != "oneshot" { return nil, fmt.Errorf("invalid service Type '%s'", serviceType) } if serviceType != "oneshot" { service.Setv(ServiceGroup, "Type", "notify", "NotifyAccess", "all") } if !kube.HasKey(ServiceGroup, "SyslogIdentifier") { service.Set(ServiceGroup, "SyslogIdentifier", "%N") } execStart := createBasePodmanCommand(kube, KubeGroup) execStart.add("kube", "play") execStart.add( // Replace any previous container with the same name, not fail "--replace", // Use a service container "--service-container=true", ) if ecp, ok := kube.Lookup(KubeGroup, KeyExitCodePropagation); ok && len(ecp) > 0 { execStart.addf("--service-exit-code-propagation=%s", ecp) } handleLogDriver(kube, KubeGroup, execStart) handleLogOpt(kube, KubeGroup, execStart) if err := handleUserMappings(kube, KubeGroup, execStart, false); err != nil { return nil, err } if err := addNetworks(kube, KubeGroup, service, unitsInfoMap, execStart); err != nil { return nil, err } updateMaps := kube.LookupAllStrv(KubeGroup, KeyAutoUpdate) for _, update := range updateMaps { annotation := fmt.Sprintf("--annotation=%s", autoUpdateLabel) updateType := update annoValue, typ, hasSlash := strings.Cut(update, "/") if hasSlash { annotation = annotation + "/" + annoValue updateType = typ } execStart.addf("%s=%s", annotation, updateType) } configMaps := kube.LookupAllStrv(KubeGroup, KeyConfigMap) for _, configMap := range configMaps { configMapPath, err := getAbsolutePath(kube, configMap) if err != nil { return nil, err } execStart.add("--configmap", configMapPath) } handlePublishPorts(kube, KubeGroup, execStart) handlePodmanArgs(kube, KubeGroup, execStart) execStart.add(yamlPath) service.AddCmdline(ServiceGroup, "ExecStart", execStart.Args) // Use `ExecStopPost` to make sure cleanup happens even in case of // errors; otherwise containers, pods, etc. would be left behind. execStop := createBasePodmanCommand(kube, KubeGroup) execStop.add("kube", "down") if kubeDownForce, ok := kube.LookupBoolean(KubeGroup, KeyKubeDownForce); ok { execStop.addBool("--force", kubeDownForce) } execStop.add(yamlPath) service.AddCmdline(ServiceGroup, "ExecStopPost", execStop.Args) _, err = handleSetWorkingDirectory(kube, service, KubeGroup) if err != nil { return nil, err } return service, nil } func ConvertImage(image *parser.UnitFile, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error) { unitInfo, ok := unitsInfoMap[image.Filename] if !ok { return nil, fmt.Errorf("internal error while processing network %s", image.Filename) } service := image.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if image.Path != "" { service.Add(UnitGroup, "SourcePath", image.Path) } if err := checkForUnknownKeys(image, ImageGroup, supportedImageKeys); err != nil { return nil, err } imageName, ok := image.Lookup(ImageGroup, KeyImage) if !ok || len(imageName) == 0 { return nil, fmt.Errorf("no Image key specified") } /* Rename old Network group to x-Network so that systemd ignores it */ service.RenameGroup(ImageGroup, XImageGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") podman := createBasePodmanCommand(image, ImageGroup) podman.add("image", "pull") stringKeys := map[string]string{ KeyArch: "--arch", KeyAuthFile: "--authfile", KeyCertDir: "--cert-dir", KeyCreds: "--creds", KeyDecryptionKey: "--decryption-key", KeyOS: "--os", KeyVariant: "--variant", KeyRetry: "--retry", KeyRetryDelay: "--retry-delay", } lookupAndAddString(image, ImageGroup, stringKeys, podman) boolKeys := map[string]string{ KeyAllTags: "--all-tags", KeyTLSVerify: "--tls-verify", } lookupAndAddBoolean(image, ImageGroup, boolKeys, podman) handlePodmanArgs(image, ImageGroup, podman) podman.add(imageName) service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) defaultOneshotServiceGroup(service, true) if name, ok := image.Lookup(ImageGroup, KeyImageTag); ok && len(name) > 0 { imageName = name } // Store the name of the created resource unitInfo.ResourceName = imageName return service, nil } func ConvertBuild(build *parser.UnitFile, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error, error) { var warn, warnings error unitInfo, ok := unitsInfoMap[build.Filename] if !ok { return nil, warnings, fmt.Errorf("internal error while processing network %s", build.Filename) } // Fast fail is ResouceName is not set if len(unitInfo.ResourceName) == 0 { return nil, warnings, fmt.Errorf("no ImageTag key specified") } service := build.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) /* Rename old Build group to X-Build so that systemd ignores it */ service.RenameGroup(BuildGroup, XBuildGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") if build.Path != "" { service.Add(UnitGroup, "SourcePath", build.Path) } if err := checkForUnknownKeys(build, BuildGroup, supportedBuildKeys); err != nil { return nil, warnings, err } podman := createBasePodmanCommand(build, BuildGroup) podman.add("build") // The `--pull` flag has to be handled separately and the `=` sign must be present // See https://github.com/containers/podman/issues/24599 for details if val, ok := build.Lookup(BuildGroup, KeyPull); ok && len(val) > 0 { podman.addf("--pull=%s", val) } stringKeys := map[string]string{ KeyArch: "--arch", KeyAuthFile: "--authfile", KeyTarget: "--target", KeyVariant: "--variant", KeyRetry: "--retry", KeyRetryDelay: "--retry-delay", } lookupAndAddString(build, BuildGroup, stringKeys, podman) boolKeys := map[string]string{ KeyTLSVerify: "--tls-verify", KeyForceRM: "--force-rm", } lookupAndAddBoolean(build, BuildGroup, boolKeys, podman) allStringKeys := map[string]string{ KeyDNS: "--dns", KeyDNSOption: "--dns-option", KeyDNSSearch: "--dns-search", KeyGroupAdd: "--group-add", KeyImageTag: "--tag", } lookupAndAddAllStrings(build, BuildGroup, allStringKeys, podman) annotations, warn := build.LookupAllKeyVal(BuildGroup, KeyAnnotation) warnings = errors.Join(warnings, warn) podman.addAnnotations(annotations) podmanEnv, warn := build.LookupAllKeyVal(BuildGroup, KeyEnvironment) warnings = errors.Join(warnings, warn) podman.addEnv(podmanEnv) labels, warn := build.LookupAllKeyVal(BuildGroup, KeyLabel) warnings = errors.Join(warnings, warn) podman.addLabels(labels) if err := addNetworks(build, BuildGroup, service, unitsInfoMap, podman); err != nil { return nil, warnings, err } secrets := build.LookupAllArgs(BuildGroup, KeySecret) for _, secret := range secrets { podman.add("--secret", secret) } if err := addVolumes(build, service, BuildGroup, unitsInfoMap, podman); err != nil { return nil, warnings, err } // In order to build an image locally, we need either a File key pointing directly at a // Containerfile, or we need a context or WorkingDirectory containing all required files. // SetWorkingDirectory= can also be a path, a URL to either a Containerfile, a Git repo, or // an archive. context, err := handleSetWorkingDirectory(build, service, BuildGroup) if err != nil { return nil, warnings, err } workingDirectory, okWD := service.Lookup(ServiceGroup, ServiceKeyWorkingDirectory) filePath, okFile := build.Lookup(BuildGroup, KeyFile) if (!okWD || len(workingDirectory) == 0) && (!okFile || len(filePath) == 0) && len(context) == 0 { return nil, warnings, fmt.Errorf("neither SetWorkingDirectory, nor File key specified") } if len(filePath) > 0 { podman.add("--file", filePath) } handlePodmanArgs(build, BuildGroup, podman) // Context or WorkingDirectory has to be last argument if len(context) > 0 { podman.add(context) } else if !filepath.IsAbs(filePath) && !isURL(filePath) { // Special handling for relative filePaths if len(workingDirectory) == 0 { return nil, warnings, fmt.Errorf("relative path in File key requires SetWorkingDirectory key to be set") } podman.add(workingDirectory) } service.AddCmdline(ServiceGroup, "ExecStart", podman.Args) defaultOneshotServiceGroup(service, false) return service, warnings, nil } func GetBuiltImageName(buildUnit *parser.UnitFile) string { imageTags := buildUnit.LookupAll(BuildGroup, KeyImageTag) if len(imageTags) > 0 { return imageTags[0] } return "" } func GetContainerServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, ContainerGroup, "") } func GetKubeServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, KubeGroup, "") } func GetVolumeServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, VolumeGroup, "-volume") } func GetNetworkServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, NetworkGroup, "-network") } func GetImageServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, ImageGroup, "-image") } func GetBuildServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, BuildGroup, "-build") } func GetPodServiceName(podUnit *parser.UnitFile) string { return getServiceName(podUnit, PodGroup, "-pod") } func getServiceName(quadletUnitFile *parser.UnitFile, groupName string, defaultExtraSuffix string) string { if serviceName, ok := quadletUnitFile.Lookup(groupName, KeyServiceName); ok { return serviceName } return removeExtension(quadletUnitFile.Filename, "", defaultExtraSuffix) } func ConvertPod(podUnit *parser.UnitFile, name string, unitsInfoMap map[string]*UnitInfo, isUser bool) (*parser.UnitFile, error) { unitInfo, ok := unitsInfoMap[podUnit.Filename] if !ok { return nil, fmt.Errorf("internal error while processing pod %s", podUnit.Filename) } service := podUnit.Dup() service.Filename = unitInfo.ServiceFileName() addDefaultDependencies(service, isUser) if podUnit.Path != "" { service.Add(UnitGroup, "SourcePath", podUnit.Path) } if err := checkForUnknownKeys(podUnit, PodGroup, supportedPodKeys); err != nil { return nil, err } // Derive pod name from unit name (with added prefix), or use user-provided name. podName, ok := podUnit.Lookup(PodGroup, KeyPodName) if !ok || len(podName) == 0 { podName = removeExtension(name, "systemd-", "") } /* Rename old Pod group to x-Pod so that systemd ignores it */ service.RenameGroup(PodGroup, XPodGroup) // Rename common quadlet group service.RenameGroup(QuadletGroup, XQuadletGroup) // Need the containers filesystem mounted to start podman service.Add(UnitGroup, "RequiresMountsFor", "%t/containers") for _, containerService := range unitInfo.ContainersToStart { service.Add(UnitGroup, "Wants", containerService) service.Add(UnitGroup, "Before", containerService) } if !podUnit.HasKey(ServiceGroup, "SyslogIdentifier") { service.Set(ServiceGroup, "SyslogIdentifier", "%N") } execStart := createBasePodmanCommand(podUnit, PodGroup) execStart.add("pod", "start", "--pod-id-file=%t/%N.pod-id") service.AddCmdline(ServiceGroup, "ExecStart", execStart.Args) execStop := createBasePodmanCommand(podUnit, PodGroup) execStop.add("pod", "stop") execStop.add( "--pod-id-file=%t/%N.pod-id", "--ignore", "--time=10", ) service.AddCmdline(ServiceGroup, "ExecStop", execStop.Args) execStopPost := createBasePodmanCommand(podUnit, PodGroup) execStopPost.add("pod", "rm") execStopPost.add( "--pod-id-file=%t/%N.pod-id", "--ignore", "--force", ) service.AddCmdline(ServiceGroup, "ExecStopPost", execStopPost.Args) execStartPre := createBasePodmanCommand(podUnit, PodGroup) execStartPre.add("pod", "create") execStartPre.add( "--infra-conmon-pidfile=%t/%N.pid", "--pod-id-file=%t/%N.pod-id", "--exit-policy=stop", "--replace", ) if err := handleUserMappings(podUnit, PodGroup, execStartPre, true); err != nil { return nil, err } handlePublishPorts(podUnit, PodGroup, execStartPre) if err := addNetworks(podUnit, PodGroup, service, unitsInfoMap, execStartPre); err != nil { return nil, err } stringsKeys := map[string]string{ KeyIP: "--ip", KeyIP6: "--ip6", KeyShmSize: "--shm-size", } lookupAndAddString(podUnit, PodGroup, stringsKeys, execStartPre) allStringsKeys := map[string]string{ KeyNetworkAlias: "--network-alias", KeyDNS: "--dns", KeyDNSOption: "--dns-option", KeyDNSSearch: "--dns-search", KeyAddHost: "--add-host", } lookupAndAddAllStrings(podUnit, PodGroup, allStringsKeys, execStartPre) if err := addVolumes(podUnit, service, PodGroup, unitsInfoMap, execStartPre); err != nil { return nil, err } execStartPre.add("--infra-name", fmt.Sprintf("%s-infra", podName)) execStartPre.add("--name", podName) handlePodmanArgs(podUnit, PodGroup, execStartPre) service.AddCmdline(ServiceGroup, "ExecStartPre", execStartPre.Args) service.Setv(ServiceGroup, "Environment", "PODMAN_SYSTEMD_UNIT=%n", "Type", "forking", "Restart", "on-failure", "PIDFile", "%t/%N.pid", ) return service, nil } func handleUser(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) error { user, hasUser := unitFile.Lookup(groupName, KeyUser) okUser := hasUser && len(user) > 0 group, hasGroup := unitFile.Lookup(groupName, KeyGroup) okGroup := hasGroup && len(group) > 0 if !okUser { if okGroup { return fmt.Errorf("invalid Group set without User") } return nil } var userGroupStr string if !okGroup { userGroupStr = user } else { userGroupStr = fmt.Sprintf("%s:%s", user, group) } podman.add("--user", userGroupStr) return nil } func handleUserMappings(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline, supportManual bool) error { mappingsDefined := false if userns, ok := unitFile.Lookup(groupName, KeyUserNS); ok && len(userns) > 0 { podman.add("--userns", userns) mappingsDefined = true } uidMaps := unitFile.LookupAllStrv(groupName, KeyUIDMap) mappingsDefined = mappingsDefined || len(uidMaps) > 0 for _, uidMap := range uidMaps { podman.add("--uidmap", uidMap) } gidMaps := unitFile.LookupAllStrv(groupName, KeyGIDMap) mappingsDefined = mappingsDefined || len(gidMaps) > 0 for _, gidMap := range gidMaps { podman.add("--gidmap", gidMap) } if subUIDMap, ok := unitFile.Lookup(groupName, KeySubUIDMap); ok && len(subUIDMap) > 0 { podman.add("--subuidname", subUIDMap) mappingsDefined = true } if subGIDMap, ok := unitFile.Lookup(groupName, KeySubGIDMap); ok && len(subGIDMap) > 0 { podman.add("--subgidname", subGIDMap) mappingsDefined = true } if mappingsDefined { _, hasRemapUID := unitFile.Lookup(groupName, KeyRemapUid) _, hasRemapGID := unitFile.Lookup(groupName, KeyRemapGid) _, RemapUsers := unitFile.LookupLast(groupName, KeyRemapUsers) if hasRemapUID || hasRemapGID || RemapUsers { return fmt.Errorf("deprecated Remap keys are set along with explicit mapping keys") } return nil } return handleUserRemap(unitFile, groupName, podman, supportManual) } func handleUserRemap(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline, supportManual bool) error { uidMaps := unitFile.LookupAllStrv(groupName, KeyRemapUid) gidMaps := unitFile.LookupAllStrv(groupName, KeyRemapGid) remapUsers, _ := unitFile.LookupLast(groupName, KeyRemapUsers) switch remapUsers { case "": if len(uidMaps) > 0 { return fmt.Errorf("UidMap set without RemapUsers") } if len(gidMaps) > 0 { return fmt.Errorf("GidMap set without RemapUsers") } case "manual": if supportManual { for _, uidMap := range uidMaps { podman.add("--uidmap", uidMap) } for _, gidMap := range gidMaps { podman.add("--gidmap", gidMap) } } else { return fmt.Errorf("RemapUsers=manual is not supported") } case "auto": autoOpts := make([]string, 0) for _, uidMap := range uidMaps { autoOpts = append(autoOpts, "uidmapping="+uidMap) } for _, gidMap := range gidMaps { autoOpts = append(autoOpts, "gidmapping="+gidMap) } uidSize := unitFile.LookupUint32(groupName, KeyRemapUidSize, 0) if uidSize > 0 { autoOpts = append(autoOpts, fmt.Sprintf("size=%v", uidSize)) } podman.add("--userns", usernsOpts("auto", autoOpts)) case "keep-id": keepidOpts := make([]string, 0) if len(uidMaps) > 0 { if len(uidMaps) > 1 { return fmt.Errorf("RemapUsers=keep-id supports only a single value for UID mapping") } keepidOpts = append(keepidOpts, "uid="+uidMaps[0]) } if len(gidMaps) > 0 { if len(gidMaps) > 1 { return fmt.Errorf("RemapUsers=keep-id supports only a single value for GID mapping") } keepidOpts = append(keepidOpts, "gid="+gidMaps[0]) } podman.add("--userns", usernsOpts("keep-id", keepidOpts)) default: return fmt.Errorf("unsupported RemapUsers option '%s'", remapUsers) } return nil } func addNetworks(quadletUnitFile *parser.UnitFile, groupName string, serviceUnitFile *parser.UnitFile, unitsInfoMap map[string]*UnitInfo, podman *PodmanCmdline) error { networks := quadletUnitFile.LookupAll(groupName, KeyNetwork) for _, network := range networks { if len(network) > 0 { quadletNetworkName, options, found := strings.Cut(network, ":") isNetworkUnit := strings.HasSuffix(quadletNetworkName, ".network") isContainerUnit := strings.HasSuffix(quadletNetworkName, ".container") if isNetworkUnit || isContainerUnit { unitInfo, ok := unitsInfoMap[quadletNetworkName] if !ok { return fmt.Errorf("requested Quadlet unit %s was not found", quadletNetworkName) } // XXX: this is usually because a '@' in service name if len(unitInfo.ResourceName) == 0 { return fmt.Errorf("cannot get the resource name of %s", quadletNetworkName) } // the systemd unit name is $serviceName.service serviceFileName := unitInfo.ServiceFileName() serviceUnitFile.Add(UnitGroup, "Requires", serviceFileName) serviceUnitFile.Add(UnitGroup, "After", serviceFileName) if found { if isContainerUnit { return fmt.Errorf("extra options are not supported when joining another container's network") } network = fmt.Sprintf("%s:%s", unitInfo.ResourceName, options) } else { if isContainerUnit { network = fmt.Sprintf("container:%s", unitInfo.ResourceName) } else { network = unitInfo.ResourceName } } } podman.add("--network", network) } } return nil } // Systemd Specifiers start with % with the exception of %% func startsWithSystemdSpecifier(filePath string) bool { if len(filePath) == 0 || filePath[0] != '%' { return false } if len(filePath) > 1 && filePath[1] == '%' { return false } return true } func getAbsolutePath(quadletUnitFile *parser.UnitFile, filePath string) (string, error) { // When the path starts with a Systemd specifier do not resolve what looks like a relative address if !startsWithSystemdSpecifier(filePath) && !filepath.IsAbs(filePath) { if len(quadletUnitFile.Path) > 0 { filePath = filepath.Join(filepath.Dir(quadletUnitFile.Path), filePath) } else { var err error filePath, err = filepath.Abs(filePath) if err != nil { return "", err } } } return filePath, nil } func handlePublishPorts(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) { publishPorts := unitFile.LookupAll(groupName, KeyPublishPort) for _, publishPort := range publishPorts { podman.add("--publish", publishPort) } } func handleLogDriver(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) { logDriver, found := unitFile.Lookup(groupName, KeyLogDriver) if found { podman.add("--log-driver", logDriver) } } func handleLogOpt(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) { logOpts := unitFile.LookupAllStrv(groupName, KeyLogOpt) for _, logOpt := range logOpts { podman.add("--log-opt", logOpt) } } func handleStorageSource(quadletUnitFile, serviceUnitFile *parser.UnitFile, source string, unitsInfoMap map[string]*UnitInfo, checkImage bool) (string, error) { if source[0] == '.' { var err error source, err = getAbsolutePath(quadletUnitFile, source) if err != nil { return "", err } } if source[0] == '/' { // Absolute path serviceUnitFile.Add(UnitGroup, "RequiresMountsFor", source) } else if strings.HasSuffix(source, ".volume") || (checkImage && strings.HasSuffix(source, ".image")) { sourceUnitInfo, ok := unitsInfoMap[source] if !ok { return "", fmt.Errorf("requested Quadlet source %s was not found", source) } // the systemd unit name is $serviceName.service sourceServiceName := sourceUnitInfo.ServiceFileName() serviceUnitFile.Add(UnitGroup, "Requires", sourceServiceName) serviceUnitFile.Add(UnitGroup, "After", sourceServiceName) source = sourceUnitInfo.ResourceName } return source, nil } func handleHealth(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) { keyArgMap := [][2]string{ {KeyHealthCmd, "cmd"}, {KeyHealthInterval, "interval"}, {KeyHealthOnFailure, "on-failure"}, {KeyHealthLogDestination, "log-destination"}, {KeyHealthMaxLogCount, "max-log-count"}, {KeyHealthMaxLogSize, "max-log-size"}, {KeyHealthRetries, "retries"}, {KeyHealthStartPeriod, "start-period"}, {KeyHealthTimeout, "timeout"}, {KeyHealthStartupCmd, "startup-cmd"}, {KeyHealthStartupInterval, "startup-interval"}, {KeyHealthStartupRetries, "startup-retries"}, {KeyHealthStartupSuccess, "startup-success"}, {KeyHealthStartupTimeout, "startup-timeout"}, } for _, keyArg := range keyArgMap { val, found := unitFile.Lookup(groupName, keyArg[0]) if found && len(val) > 0 { podman.addf("--health-%s", keyArg[1]) podman.addf("%s", val) } } } func handlePodmanArgs(unitFile *parser.UnitFile, groupName string, podman *PodmanCmdline) { podmanArgs := unitFile.LookupAllArgs(groupName, KeyPodmanArgs) if len(podmanArgs) > 0 { podman.add(podmanArgs...) } } func handleSetWorkingDirectory(quadletUnitFile, serviceUnitFile *parser.UnitFile, quadletGroup string) (string, error) { setWorkingDirectory, ok := quadletUnitFile.Lookup(quadletGroup, KeySetWorkingDirectory) if !ok || len(setWorkingDirectory) == 0 { return "", nil } var relativeToFile string var context string switch strings.ToLower(setWorkingDirectory) { case "yaml": if quadletGroup != KubeGroup { return "", fmt.Errorf("SetWorkingDirectory=%s is only supported in .kube files", setWorkingDirectory) } relativeToFile, ok = quadletUnitFile.Lookup(quadletGroup, KeyYaml) if !ok { return "", fmt.Errorf("no Yaml key specified") } case "file": if quadletGroup != BuildGroup { return "", fmt.Errorf("SetWorkingDirectory=%s is only supported in .build files", setWorkingDirectory) } relativeToFile, ok = quadletUnitFile.Lookup(quadletGroup, KeyFile) if !ok { return "", fmt.Errorf("no File key specified") } case "unit": relativeToFile = quadletUnitFile.Path default: // Path / URL handling is for .build files only if quadletGroup != BuildGroup { return "", fmt.Errorf("unsupported value for %s: %s ", ServiceKeyWorkingDirectory, setWorkingDirectory) } // Any value other than the above cases will be returned as context context = setWorkingDirectory // If we have a relative path, set the WorkingDirectory to that of the // quadletUnitFile if !filepath.IsAbs(context) { relativeToFile = quadletUnitFile.Path } } if len(relativeToFile) > 0 && !isURL(context) { // If WorkingDirectory is already set in the Service section do not change it workingDir, ok := quadletUnitFile.Lookup(ServiceGroup, ServiceKeyWorkingDirectory) if ok && len(workingDir) > 0 { return "", nil } fileInWorkingDir, err := getAbsolutePath(quadletUnitFile, relativeToFile) if err != nil { return "", err } serviceUnitFile.Add(ServiceGroup, ServiceKeyWorkingDirectory, filepath.Dir(fileInWorkingDir)) } return context, nil } func lookupAndAddString(unit *parser.UnitFile, group string, keys map[string]string, podman *PodmanCmdline) { for key, flag := range keys { if val, ok := unit.Lookup(group, key); ok && len(val) > 0 { podman.add(flag, val) } } } func lookupAndAddAllStrings(unit *parser.UnitFile, group string, keys map[string]string, podman *PodmanCmdline) { for key, flag := range keys { values := unit.LookupAll(group, key) for _, val := range values { podman.add(flag, val) } } } func lookupAndAddBoolean(unit *parser.UnitFile, group string, keys map[string]string, podman *PodmanCmdline) { for key, flag := range keys { if val, ok := unit.LookupBoolean(group, key); ok { podman.addBool(flag, val) } } } func handleImageSource(quadletImageName string, serviceUnitFile *parser.UnitFile, unitsInfoMap map[string]*UnitInfo) (string, error) { for _, suffix := range []string{".build", ".image"} { if strings.HasSuffix(quadletImageName, suffix) { // since there is no default name conversion, the actual image name must exist in the names map unitInfo, ok := unitsInfoMap[quadletImageName] if !ok { return "", fmt.Errorf("requested Quadlet image %s was not found", quadletImageName) } // the systemd unit name is $name-$suffix.service imageServiceName := unitInfo.ServiceFileName() serviceUnitFile.Add(UnitGroup, "Requires", imageServiceName) serviceUnitFile.Add(UnitGroup, "After", imageServiceName) quadletImageName = unitInfo.ResourceName } } return quadletImageName, nil } func resolveContainerMountParams(containerUnitFile, serviceUnitFile *parser.UnitFile, mount string, unitsInfoMap map[string]*UnitInfo) (string, error) { mountType, tokens, err := specgenutilexternal.FindMountType(mount) if err != nil { return "", err } // Source resolution is required only for these types of mounts sourceResultionRequired := map[string]struct{}{ "volume": {}, "bind": {}, "glob": {}, "image": {}, } if _, ok := sourceResultionRequired[mountType]; !ok { return mount, nil } sourceIndex := -1 originalSource := "" for i, token := range tokens { key, val, hasVal := strings.Cut(token, "=") if key == "source" || key == "src" { if !hasVal { return "", fmt.Errorf("source parameter does not include a value") } sourceIndex = i originalSource = val } } resolvedSource, err := handleStorageSource(containerUnitFile, serviceUnitFile, originalSource, unitsInfoMap, true) if err != nil { return "", err } tokens[sourceIndex] = fmt.Sprintf("source=%s", resolvedSource) tokens = append([]string{fmt.Sprintf("type=%s", mountType)}, tokens...) return convertToCSV(tokens) } func convertToCSV(s []string) (string, error) { var buf bytes.Buffer writer := csv.NewWriter(&buf) err := writer.Write(s) if err != nil { return "", err } writer.Flush() ret := buf.String() if ret[len(ret)-1] == '\n' { ret = ret[:len(ret)-1] } return ret, nil } func createBasePodmanCommand(unitFile *parser.UnitFile, groupName string) *PodmanCmdline { podman := NewPodmanCmdline() containersConfModules := unitFile.LookupAll(groupName, KeyContainersConfModule) for _, containersConfModule := range containersConfModules { podman.addf("--module=%s", containersConfModule) } globalArgs := unitFile.LookupAllArgs(groupName, KeyGlobalArgs) if len(globalArgs) > 0 { podman.add(globalArgs...) } return podman } func handlePod(quadletUnitFile, serviceUnitFile *parser.UnitFile, groupName string, unitsInfoMap map[string]*UnitInfo, podman *PodmanCmdline) error { pod, ok := quadletUnitFile.Lookup(groupName, KeyPod) if ok && len(pod) > 0 { if !strings.HasSuffix(pod, ".pod") { return fmt.Errorf("pod %s is not Quadlet based", pod) } podInfo, ok := unitsInfoMap[pod] if !ok { return fmt.Errorf("quadlet pod unit %s does not exist", pod) } podman.add("--pod-id-file", fmt.Sprintf("%%t/%s.pod-id", podInfo.ServiceName)) podServiceName := podInfo.ServiceFileName() serviceUnitFile.Add(UnitGroup, "BindsTo", podServiceName) serviceUnitFile.Add(UnitGroup, "After", podServiceName) // If we want to start the container with the pod, we add it to this list. // This creates corresponding Wants=/Before= statements in the pod service. if quadletUnitFile.LookupBooleanWithDefault(groupName, KeyStartWithPod, true) { podInfo.ContainersToStart = append(podInfo.ContainersToStart, serviceUnitFile.Filename) } } return nil } func addVolumes(quadletUnitFile, serviceUnitFile *parser.UnitFile, groupName string, unitsInfoMap map[string]*UnitInfo, podman *PodmanCmdline) error { volumes := quadletUnitFile.LookupAll(groupName, 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 != "" { var err error source, err = handleStorageSource(quadletUnitFile, serviceUnitFile, source, unitsInfoMap, false) if err != nil { return err } } podman.add("-v") if source == "" { podman.add(dest) } else { podman.addf("%s:%s%s", source, dest, options) } } return nil } func addDefaultDependencies(service *parser.UnitFile, isUser bool) { // Add a dependency on network-online.target so the image pull container startup // does not happen before network is ready. // https://github.com/containers/podman/issues/21873 if service.LookupBooleanWithDefault(QuadletGroup, KeyDefaultDependencies, true) { networkUnit := "network-online.target" // network-online.target only exists as root and user session cannot wait for it // https://github.com/systemd/systemd/issues/3312 // Given this is a bad problem with pasta which can fail to start or use the // wrong interface if the network is not fully set up we need to work around // that: https://github.com/containers/podman/issues/22197. if isUser { networkUnit = "podman-user-wait-network-online.service" } service.PrependUnitLine(UnitGroup, "After", networkUnit) service.PrependUnitLine(UnitGroup, "Wants", networkUnit) } }