Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support templates in quadlet #21068

Merged
merged 6 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 41 additions & 12 deletions cmd/quadlet/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,22 @@ func loadUnitDropins(unit *parser.UnitFile, sourcePaths []string) error {
prevError = err
}

var dropinPaths = make(map[string]string)
dropinDirs := []string{}

for _, sourcePath := range sourcePaths {
dropinDir := path.Join(sourcePath, unit.Filename+".d")
dropinDirs = append(dropinDirs, path.Join(sourcePath, unit.Filename+".d"))
}

// For instantiated templates, also look in the non-instanced template dropin dirs
templateBase, templateInstance := unit.GetTemplateParts()
if templateBase != "" && templateInstance != "" {
for _, sourcePath := range sourcePaths {
dropinDirs = append(dropinDirs, path.Join(sourcePath, templateBase+".d"))
}
}

var dropinPaths = make(map[string]string)
for _, dropinDir := range dropinDirs {
dropinFiles, err := os.ReadDir(dropinDir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
Expand Down Expand Up @@ -345,19 +357,36 @@ func enableServiceFile(outputPath string, service *parser.UnitFile) {
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))
serviceFilename := service.Filename
templateBase, templateInstance := service.GetTemplateParts()

// For non-instantiated template service we only support installs if a
// DefaultInstance is given. Otherwise we ignore the Install group, but
// it is still useful when instantiating the unit via a symlink.
if templateBase != "" && templateInstance == "" {
if defaultInstance, ok := service.Lookup(quadlet.InstallGroup, "DefaultInstance"); ok {
parts := strings.SplitN(templateBase, "@", 2)
serviceFilename = parts[0] + "@" + defaultInstance + parts[1]
} else {
serviceFilename = ""
}
}

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))
if serviceFilename != "" {
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, serviceFilename))
}
}

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

Expand Down
78 changes: 74 additions & 4 deletions docs/source/markdown/podman-systemd.unit.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,16 +109,86 @@ WantedBy=default.target

Currently, only the `Alias`, `WantedBy` and `RequiredBy` keys are supported.

The Install section can be part of the main file, or it can be in a
separate drop-in file as described above. The latter allows you to
install an non-enabled unit and then later enabling it by installing
the drop-in.


**NOTE:** To express dependencies between containers, use the generated names of the service. In other
words `WantedBy=other.service`, not `WantedBy=other.container`. The same is
true for other kinds of dependencies, too, like `After=other.service`.

### Template files
alexlarsson marked this conversation as resolved.
Show resolved Hide resolved

Systemd supports a concept of [template files](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Service%20Templates).
They are units with names of the form "[email protected]"
when they are running, but that can be instantiated multiple times
from a single "[email protected]" file. The individual instances can
also be different by using drop-in files with the full instance name.

Quadlets support these in two ways. First of all, a quadlet unit with
a template form will generate a systemd service with a template form,
and the template systemd service can be used as a regular template.
For example, "[email protected]" will generate "[email protected]" and you can
then "systemctl start [email protected]".

Secondly, if you make a symlink like "[email protected]", that
will generate an instantiated template file. When generating this file
quadlet will read drop-in files both from the instanced directory
([email protected]) and the template directory
([email protected]). This allows customization of individual instances.

Instanced template files (like `[email protected]`) can be enabled
just like non-templated ones. However, templated ones
(`[email protected]`) are different, because they need to be
instantiated. If the `[Install]` section contains a `DefaultInstance=`
key, then that instance will be enabled, but if not, nothing will
happen and the options will only be used as the default for units
that are instantiated using symlinks.

An example template file `[email protected]` might look like this:

```
[Unit]
Description=A templated sleepy container

[Container]
Image=quay.io/fedora/fedora
Exec=sleep %i

[Service]
# Restart service when sleep finishes
Restart=always

[Install]
WantedBy=multi-user.target
DefaultInstance=100
```

If this is installed, then on boot there will be a `[email protected]`
running that sleeps for 100 seconds. You can then do something like
`systemctl start [email protected]` to start another instance that
sleeps 50 seconds, or alternatively another service can start it via a
dependency like `[email protected]`.

In addition, if you do `ln -s [email protected] [email protected]` you
will also have a 10 second sleep running at boot. And, if you want
that particular instance to be running with another image, you can
create a drop-in file like `[email protected]/10-image.conf`:
```
[Container]
Image=quay.io/centos/centos
```

### Debugging unit files

After placing the unit file in one of the unit search paths (mentioned above), you can start it with
`systemctl start {--user}`. If it fails with "Failed to start example.service: Unit example.service not found.",
then it is possible that you used incorrect syntax or you used an option from a newer version of Podman
Quadlet and the generator failed to create a service file.
After placing the unit file in one of the unit search paths (mentioned
above), you can start it with `systemctl start {--user}`. If it fails
with "Failed to start example.service: Unit example.service not
found.", then it is possible that you used incorrect syntax or you
used an option from a newer version of Podman Quadlet and the
generator failed to create a service file.

View the generated files and/or error messages with:
```
Expand Down
11 changes: 11 additions & 0 deletions pkg/systemd/parser/unitfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"os/user"
"path"
"path/filepath"
"strconv"
"strings"
"unicode"
Expand Down Expand Up @@ -919,3 +920,13 @@ func (f *UnitFile) PrependComment(groupName string, comments ...string) {
group.prependComment(newUnitLine("", "# "+comments[i], true))
}
}

func (f *UnitFile) GetTemplateParts() (string, string) {
ext := filepath.Ext(f.Filename)
basename := strings.TrimSuffix(f.Filename, ext)
parts := strings.SplitN(basename, "@", 2)
if len(parts) < 2 {
return "", ""
}
return parts[0] + "@" + ext, parts[1]
}
6 changes: 5 additions & 1 deletion pkg/systemd/quadlet/quadlet.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,11 @@ func ConvertContainer(container *parser.UnitFile, names map[string]string, isUse
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"
if strings.Contains(container.Filename, "@") {
containerName = "systemd-%P_%I"
} else {
containerName = "systemd-%N"
}
}

// Set PODMAN_SYSTEMD_UNIT so that podman auto-update can restart the service.
Expand Down
11 changes: 11 additions & 0 deletions test/e2e/quadlet/[email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## assert-podman-final-args localhost/imagename
## assert-podman-args "--name=systemd-%P_%I"
## assert-symlink want.service.wants/[email protected] ../[email protected]
## assert-podman-args --env "FOO=bar"

[Container]
Image=localhost/imagename

[Install]
WantedBy=want.service
DefaultInstance=default
2 changes: 2 additions & 0 deletions test/e2e/quadlet/[email protected]/10-env.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[Container]
Environment=FOO=bar
11 changes: 11 additions & 0 deletions test/e2e/quadlet/[email protected]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## assert-podman-final-args localhost/changed-image
## assert-podman-args "--name=systemd-%P_%I"
## assert-symlink want.service.wants/[email protected] ../[email protected]
## assert-podman-args --env "FOO=bar"

[Container]
# Will be changed by /[email protected]/10-image.conf
Image=localhost/imagename

[Install]
WantedBy=want.service
2 changes: 2 additions & 0 deletions test/e2e/quadlet/[email protected]/10-image.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[Container]
Image=localhost/changed-image
33 changes: 26 additions & 7 deletions test/e2e/quadlet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ type quadletTestcase struct {
checks [][]string
}

// Converts "[email protected]" to "[email protected]"
func getGenericTemplateFile(fileName string) (bool, string) {
extension := filepath.Ext(fileName)
base := strings.TrimSuffix(fileName, extension)
parts := strings.SplitN(base, "@", 2)
if len(parts) == 2 && len(parts[1]) > 0 {
return true, parts[0] + "@" + extension
}
return false, ""
}

func loadQuadletTestcase(path string) *quadletTestcase {
data, err := os.ReadFile(path)
Expect(err).ToNot(HaveOccurred())
Expand Down Expand Up @@ -724,13 +735,19 @@ BOGUS=foo
Expect(err).ToNot(HaveOccurred())

// Also copy any extra snippets
dotdDir := filepath.Join("quadlet", fileName+".d")
if s, err := os.Stat(dotdDir); err == nil && s.IsDir() {
dotdDirDest := filepath.Join(quadletDir, fileName+".d")
err = os.Mkdir(dotdDirDest, os.ModePerm)
Expect(err).ToNot(HaveOccurred())
err = CopyDirectory(dotdDir, dotdDirDest)
Expect(err).ToNot(HaveOccurred())
snippetdirs := []string{fileName + ".d"}
if ok, genericFileName := getGenericTemplateFile(fileName); ok {
snippetdirs = append(snippetdirs, genericFileName+".d")
}
for _, snippetdir := range snippetdirs {
dotdDir := filepath.Join("quadlet", snippetdir)
if s, err := os.Stat(dotdDir); err == nil && s.IsDir() {
dotdDirDest := filepath.Join(quadletDir, snippetdir)
err = os.Mkdir(dotdDirDest, os.ModePerm)
Expect(err).ToNot(HaveOccurred())
err = CopyDirectory(dotdDir, dotdDirDest)
Expect(err).ToNot(HaveOccurred())
}
}

// Run quadlet to convert the file
Expand Down Expand Up @@ -825,6 +842,8 @@ BOGUS=foo
Entry("Container - Containers Conf Modules", "containersconfmodule.container", 0, ""),
Entry("merged.container", "merged.container", 0, ""),
Entry("merged-override.container", "merged-override.container", 0, ""),
Entry("[email protected]", "[email protected]", 0, ""),
Entry("[email protected]", "[email protected]", 0, ""),

Entry("basic.volume", "basic.volume", 0, ""),
Entry("device-copy.volume", "device-copy.volume", 0, ""),
Expand Down
Loading