Skip to content

Commit 5e64d4f

Browse files
Merge pull request #21068 from alexlarsson/quadlet-templates
Support templates in quadlet
2 parents 7cb0c2e + cd5982e commit 5e64d4f

File tree

9 files changed

+183
-24
lines changed

9 files changed

+183
-24
lines changed

cmd/quadlet/main.go

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,22 @@ func loadUnitDropins(unit *parser.UnitFile, sourcePaths []string) error {
254254
prevError = err
255255
}
256256

257-
var dropinPaths = make(map[string]string)
257+
dropinDirs := []string{}
258+
258259
for _, sourcePath := range sourcePaths {
259-
dropinDir := path.Join(sourcePath, unit.Filename+".d")
260+
dropinDirs = append(dropinDirs, path.Join(sourcePath, unit.Filename+".d"))
261+
}
260262

263+
// For instantiated templates, also look in the non-instanced template dropin dirs
264+
templateBase, templateInstance := unit.GetTemplateParts()
265+
if templateBase != "" && templateInstance != "" {
266+
for _, sourcePath := range sourcePaths {
267+
dropinDirs = append(dropinDirs, path.Join(sourcePath, templateBase+".d"))
268+
}
269+
}
270+
271+
var dropinPaths = make(map[string]string)
272+
for _, dropinDir := range dropinDirs {
261273
dropinFiles, err := os.ReadDir(dropinDir)
262274
if err != nil {
263275
if !errors.Is(err, os.ErrNotExist) {
@@ -345,19 +357,36 @@ func enableServiceFile(outputPath string, service *parser.UnitFile) {
345357
symlinks = append(symlinks, filepath.Clean(alias))
346358
}
347359

348-
wantedBy := service.LookupAllStrv(quadlet.InstallGroup, "WantedBy")
349-
for _, wantedByUnit := range wantedBy {
350-
// Only allow filenames, not paths
351-
if !strings.Contains(wantedByUnit, "/") {
352-
symlinks = append(symlinks, fmt.Sprintf("%s.wants/%s", wantedByUnit, service.Filename))
360+
serviceFilename := service.Filename
361+
templateBase, templateInstance := service.GetTemplateParts()
362+
363+
// For non-instantiated template service we only support installs if a
364+
// DefaultInstance is given. Otherwise we ignore the Install group, but
365+
// it is still useful when instantiating the unit via a symlink.
366+
if templateBase != "" && templateInstance == "" {
367+
if defaultInstance, ok := service.Lookup(quadlet.InstallGroup, "DefaultInstance"); ok {
368+
parts := strings.SplitN(templateBase, "@", 2)
369+
serviceFilename = parts[0] + "@" + defaultInstance + parts[1]
370+
} else {
371+
serviceFilename = ""
353372
}
354373
}
355374

356-
requiredBy := service.LookupAllStrv(quadlet.InstallGroup, "RequiredBy")
357-
for _, requiredByUnit := range requiredBy {
358-
// Only allow filenames, not paths
359-
if !strings.Contains(requiredByUnit, "/") {
360-
symlinks = append(symlinks, fmt.Sprintf("%s.requires/%s", requiredByUnit, service.Filename))
375+
if serviceFilename != "" {
376+
wantedBy := service.LookupAllStrv(quadlet.InstallGroup, "WantedBy")
377+
for _, wantedByUnit := range wantedBy {
378+
// Only allow filenames, not paths
379+
if !strings.Contains(wantedByUnit, "/") {
380+
symlinks = append(symlinks, fmt.Sprintf("%s.wants/%s", wantedByUnit, serviceFilename))
381+
}
382+
}
383+
384+
requiredBy := service.LookupAllStrv(quadlet.InstallGroup, "RequiredBy")
385+
for _, requiredByUnit := range requiredBy {
386+
// Only allow filenames, not paths
387+
if !strings.Contains(requiredByUnit, "/") {
388+
symlinks = append(symlinks, fmt.Sprintf("%s.requires/%s", requiredByUnit, serviceFilename))
389+
}
361390
}
362391
}
363392

docs/source/markdown/podman-systemd.unit.5.md

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,86 @@ WantedBy=default.target
109109

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

112+
The Install section can be part of the main file, or it can be in a
113+
separate drop-in file as described above. The latter allows you to
114+
install an non-enabled unit and then later enabling it by installing
115+
the drop-in.
116+
117+
112118
**NOTE:** To express dependencies between containers, use the generated names of the service. In other
113119
words `WantedBy=other.service`, not `WantedBy=other.container`. The same is
114120
true for other kinds of dependencies, too, like `After=other.service`.
115121

122+
### Template files
123+
124+
Systemd supports a concept of [template files](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html#Service%20Templates).
125+
They are units with names of the form "[email protected]"
126+
when they are running, but that can be instantiated multiple times
127+
from a single "[email protected]" file. The individual instances can
128+
also be different by using drop-in files with the full instance name.
129+
130+
Quadlets support these in two ways. First of all, a quadlet unit with
131+
a template form will generate a systemd service with a template form,
132+
and the template systemd service can be used as a regular template.
133+
For example, "[email protected]" will generate "[email protected]" and you can
134+
then "systemctl start [email protected]".
135+
136+
Secondly, if you make a symlink like "[email protected]", that
137+
will generate an instantiated template file. When generating this file
138+
quadlet will read drop-in files both from the instanced directory
139+
([email protected]) and the template directory
140+
([email protected]). This allows customization of individual instances.
141+
142+
Instanced template files (like `[email protected]`) can be enabled
143+
just like non-templated ones. However, templated ones
144+
(`[email protected]`) are different, because they need to be
145+
instantiated. If the `[Install]` section contains a `DefaultInstance=`
146+
key, then that instance will be enabled, but if not, nothing will
147+
happen and the options will only be used as the default for units
148+
that are instantiated using symlinks.
149+
150+
An example template file `[email protected]` might look like this:
151+
152+
```
153+
[Unit]
154+
Description=A templated sleepy container
155+
156+
[Container]
157+
Image=quay.io/fedora/fedora
158+
Exec=sleep %i
159+
160+
[Service]
161+
# Restart service when sleep finishes
162+
Restart=always
163+
164+
[Install]
165+
WantedBy=multi-user.target
166+
DefaultInstance=100
167+
```
168+
169+
If this is installed, then on boot there will be a `[email protected]`
170+
running that sleeps for 100 seconds. You can then do something like
171+
`systemctl start [email protected]` to start another instance that
172+
sleeps 50 seconds, or alternatively another service can start it via a
173+
dependency like `[email protected]`.
174+
175+
In addition, if you do `ln -s [email protected] [email protected]` you
176+
will also have a 10 second sleep running at boot. And, if you want
177+
that particular instance to be running with another image, you can
178+
create a drop-in file like `[email protected]/10-image.conf`:
179+
```
180+
[Container]
181+
Image=quay.io/centos/centos
182+
```
183+
116184
### Debugging unit files
117185

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

123193
View the generated files and/or error messages with:
124194
```

pkg/systemd/parser/unitfile.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"os/user"
99
"path"
10+
"path/filepath"
1011
"strconv"
1112
"strings"
1213
"unicode"
@@ -919,3 +920,13 @@ func (f *UnitFile) PrependComment(groupName string, comments ...string) {
919920
group.prependComment(newUnitLine("", "# "+comments[i], true))
920921
}
921922
}
923+
924+
func (f *UnitFile) GetTemplateParts() (string, string) {
925+
ext := filepath.Ext(f.Filename)
926+
basename := strings.TrimSuffix(f.Filename, ext)
927+
parts := strings.SplitN(basename, "@", 2)
928+
if len(parts) < 2 {
929+
return "", ""
930+
}
931+
return parts[0] + "@" + ext, parts[1]
932+
}

pkg/systemd/quadlet/quadlet.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,11 @@ func ConvertContainer(container *parser.UnitFile, names map[string]string, isUse
439439
containerName, ok := container.Lookup(ContainerGroup, KeyContainerName)
440440
if !ok || len(containerName) == 0 {
441441
// By default, We want to name the container by the service name
442-
containerName = "systemd-%N"
442+
if strings.Contains(container.Filename, "@") {
443+
containerName = "systemd-%P_%I"
444+
} else {
445+
containerName = "systemd-%N"
446+
}
443447
}
444448

445449
// Set PODMAN_SYSTEMD_UNIT so that podman auto-update can restart the service.

test/e2e/quadlet/[email protected]

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## assert-podman-final-args localhost/imagename
2+
## assert-podman-args "--name=systemd-%P_%I"
3+
## assert-symlink want.service.wants/[email protected] ../[email protected]
4+
## assert-podman-args --env "FOO=bar"
5+
6+
[Container]
7+
Image=localhost/imagename
8+
9+
[Install]
10+
WantedBy=want.service
11+
DefaultInstance=default
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[Container]
2+
Environment=FOO=bar

test/e2e/quadlet/[email protected]

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
## assert-podman-final-args localhost/changed-image
2+
## assert-podman-args "--name=systemd-%P_%I"
3+
## assert-symlink want.service.wants/[email protected] ../[email protected]
4+
## assert-podman-args --env "FOO=bar"
5+
6+
[Container]
7+
# Will be changed by /[email protected]/10-image.conf
8+
Image=localhost/imagename
9+
10+
[Install]
11+
WantedBy=want.service
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[Container]
2+
Image=localhost/changed-image

test/e2e/quadlet_test.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,17 @@ type quadletTestcase struct {
2525
checks [][]string
2626
}
2727

28+
29+
func getGenericTemplateFile(fileName string) (bool, string) {
30+
extension := filepath.Ext(fileName)
31+
base := strings.TrimSuffix(fileName, extension)
32+
parts := strings.SplitN(base, "@", 2)
33+
if len(parts) == 2 && len(parts[1]) > 0 {
34+
return true, parts[0] + "@" + extension
35+
}
36+
return false, ""
37+
}
38+
2839
func loadQuadletTestcase(path string) *quadletTestcase {
2940
data, err := os.ReadFile(path)
3041
Expect(err).ToNot(HaveOccurred())
@@ -724,13 +735,19 @@ BOGUS=foo
724735
Expect(err).ToNot(HaveOccurred())
725736

726737
// Also copy any extra snippets
727-
dotdDir := filepath.Join("quadlet", fileName+".d")
728-
if s, err := os.Stat(dotdDir); err == nil && s.IsDir() {
729-
dotdDirDest := filepath.Join(quadletDir, fileName+".d")
730-
err = os.Mkdir(dotdDirDest, os.ModePerm)
731-
Expect(err).ToNot(HaveOccurred())
732-
err = CopyDirectory(dotdDir, dotdDirDest)
733-
Expect(err).ToNot(HaveOccurred())
738+
snippetdirs := []string{fileName + ".d"}
739+
if ok, genericFileName := getGenericTemplateFile(fileName); ok {
740+
snippetdirs = append(snippetdirs, genericFileName+".d")
741+
}
742+
for _, snippetdir := range snippetdirs {
743+
dotdDir := filepath.Join("quadlet", snippetdir)
744+
if s, err := os.Stat(dotdDir); err == nil && s.IsDir() {
745+
dotdDirDest := filepath.Join(quadletDir, snippetdir)
746+
err = os.Mkdir(dotdDirDest, os.ModePerm)
747+
Expect(err).ToNot(HaveOccurred())
748+
err = CopyDirectory(dotdDir, dotdDirDest)
749+
Expect(err).ToNot(HaveOccurred())
750+
}
734751
}
735752

736753
// Run quadlet to convert the file
@@ -826,6 +843,8 @@ BOGUS=foo
826843
Entry("Container - Containers Conf Modules", "containersconfmodule.container", 0, ""),
827844
Entry("merged.container", "merged.container", 0, ""),
828845
Entry("merged-override.container", "merged-override.container", 0, ""),
846+
847+
829848

830849
Entry("basic.volume", "basic.volume", 0, ""),
831850
Entry("device-copy.volume", "device-copy.volume", 0, ""),

0 commit comments

Comments
 (0)