From 2df994ba0c5a98c0668ef7034c1b6259cd3fc3fe Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 20 Dec 2023 15:26:08 +0100 Subject: [PATCH 1/6] quadlet: Don't put @ in container names for templated units This is not supported by podman, so we make "foo@bar" into "foo_bar". Signed-off-by: Alexander Larsson --- pkg/systemd/quadlet/quadlet.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/systemd/quadlet/quadlet.go b/pkg/systemd/quadlet/quadlet.go index 62c2f53596..6890a33fcb 100644 --- a/pkg/systemd/quadlet/quadlet.go +++ b/pkg/systemd/quadlet/quadlet.go @@ -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. From 7e1942ed46f55b42816ecbdff52912b04e607310 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 20 Dec 2023 15:27:11 +0100 Subject: [PATCH 2/6] systemd.parser: Add GetTemplateParts() This helper splits out a templated filename into the base template and the instance name. This will be used later. Signed-off-by: Alexander Larsson --- pkg/systemd/parser/unitfile.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/systemd/parser/unitfile.go b/pkg/systemd/parser/unitfile.go index dadd7d849d..bfa850219c 100644 --- a/pkg/systemd/parser/unitfile.go +++ b/pkg/systemd/parser/unitfile.go @@ -7,6 +7,7 @@ import ( "os" "os/user" "path" + "path/filepath" "strconv" "strings" "unicode" @@ -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] +} From bb6dec46ff8f3123334df27634f9a80fd47703d8 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 20 Dec 2023 15:28:25 +0100 Subject: [PATCH 3/6] quadlet: Support [Install] for templated units For a base template like `foo@.container` the WantedBy and RequiredBy keys does nothing. However, if a DefaultInstance= key is specified that is used by default. However, even if the DefaultInstance= is not given, the Install section is still useful, because you can instantiate the generic template by making a symlink for it, and that symlink will then pick up the instance id. So, for example, this foo@.container will not enable anything on boot. ``` [Container] Image=foo Exec=sleep 100 [Install] WantedBy=other.container ``` But if you have a symlink 'foo@instance.container` -> `foo@.container' then the `foo@instance` service will be marked as wanted by `other`. In addition, even if the main template doesn't have an Install section, you can instantiate it with a symlink like above, and then enabling it using a dropin file like foo@instance.container.d/install.conf containing: ``` [Install] WantedBy=other.container ``` Signed-off-by: Alexander Larsson --- cmd/quadlet/main.go | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/cmd/quadlet/main.go b/cmd/quadlet/main.go index 2f1c6387ae..447820dae2 100644 --- a/cmd/quadlet/main.go +++ b/cmd/quadlet/main.go @@ -345,19 +345,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)) + } } } From 01dccba50c101d3a12bac4a5eaf34ea8ebd12352 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 20 Dec 2023 17:00:03 +0100 Subject: [PATCH 4/6] quadlet: When loading dropin files for foo@instance, also load those for foo@. This is how systemd works for templates, and it allows us lots of flexibilities. Signed-off-by: Alexander Larsson --- cmd/quadlet/main.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/quadlet/main.go b/cmd/quadlet/main.go index 447820dae2..736cff2a30 100644 --- a/cmd/quadlet/main.go +++ b/cmd/quadlet/main.go @@ -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) { From dc94a10d68ed66c38a611c29d1ae8777f545bf92 Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Wed, 20 Dec 2023 17:14:20 +0100 Subject: [PATCH 5/6] quadlet: Add documentation about template use to manpage Signed-off-by: Alexander Larsson --- docs/source/markdown/podman-systemd.unit.5.md | 78 ++++++++++++++++++- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/source/markdown/podman-systemd.unit.5.md b/docs/source/markdown/podman-systemd.unit.5.md index eecee580e0..70d6776d24 100644 --- a/docs/source/markdown/podman-systemd.unit.5.md +++ b/docs/source/markdown/podman-systemd.unit.5.md @@ -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 + +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 "basename@instancename.service" +when they are running, but that can be instantiated multiple times +from a single "basename@.service" 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, "foo@.container" will generate "foo@.service" and you can +then "systemctl start foo@bar.service". + +Secondly, if you make a symlink like "foo@instance.container", that +will generate an instantiated template file. When generating this file +quadlet will read drop-in files both from the instanced directory +(foo@instance.container.d) and the template directory +(foo@.container.d). This allows customization of individual instances. + +Instanced template files (like `foo@bar.container`) can be enabled +just like non-templated ones. However, templated ones +(`foo@.container`) 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 `sleep@.container` 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 `sleep@100.service` +running that sleeps for 100 seconds. You can then do something like +`systemctl start sleep@50.service` to start another instance that +sleeps 50 seconds, or alternatively another service can start it via a +dependency like `Wants=sleep@50.service`. + +In addition, if you do `ln -s sleep@.container sleep@10.container` 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 `sleep@10.container.d/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: ``` From cd5982e9886b88d52b430f800a5932b0ccb323cf Mon Sep 17 00:00:00 2001 From: Alexander Larsson Date: Tue, 23 Jan 2024 17:07:53 +0100 Subject: [PATCH 6/6] quadlet: Add tests for templates Signed-off-by: Alexander Larsson --- test/e2e/quadlet/template@.container | 11 +++++++ .../quadlet/template@.container.d/10-env.conf | 2 ++ test/e2e/quadlet/template@instance.container | 11 +++++++ .../10-image.conf | 2 ++ test/e2e/quadlet_test.go | 33 +++++++++++++++---- 5 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 test/e2e/quadlet/template@.container create mode 100644 test/e2e/quadlet/template@.container.d/10-env.conf create mode 100644 test/e2e/quadlet/template@instance.container create mode 100644 test/e2e/quadlet/template@instance.container.d/10-image.conf diff --git a/test/e2e/quadlet/template@.container b/test/e2e/quadlet/template@.container new file mode 100644 index 0000000000..5f17e8bf61 --- /dev/null +++ b/test/e2e/quadlet/template@.container @@ -0,0 +1,11 @@ +## assert-podman-final-args localhost/imagename +## assert-podman-args "--name=systemd-%P_%I" +## assert-symlink want.service.wants/template@default.service ../template@.service +## assert-podman-args --env "FOO=bar" + +[Container] +Image=localhost/imagename + +[Install] +WantedBy=want.service +DefaultInstance=default diff --git a/test/e2e/quadlet/template@.container.d/10-env.conf b/test/e2e/quadlet/template@.container.d/10-env.conf new file mode 100644 index 0000000000..039e6fe2e6 --- /dev/null +++ b/test/e2e/quadlet/template@.container.d/10-env.conf @@ -0,0 +1,2 @@ +[Container] +Environment=FOO=bar diff --git a/test/e2e/quadlet/template@instance.container b/test/e2e/quadlet/template@instance.container new file mode 100644 index 0000000000..0144e5e7ee --- /dev/null +++ b/test/e2e/quadlet/template@instance.container @@ -0,0 +1,11 @@ +## assert-podman-final-args localhost/changed-image +## assert-podman-args "--name=systemd-%P_%I" +## assert-symlink want.service.wants/template@instance.service ../template@instance.service +## assert-podman-args --env "FOO=bar" + +[Container] +# Will be changed by /template@instance.container.d/10-image.conf +Image=localhost/imagename + +[Install] +WantedBy=want.service diff --git a/test/e2e/quadlet/template@instance.container.d/10-image.conf b/test/e2e/quadlet/template@instance.container.d/10-image.conf new file mode 100644 index 0000000000..25dcaa0ba7 --- /dev/null +++ b/test/e2e/quadlet/template@instance.container.d/10-image.conf @@ -0,0 +1,2 @@ +[Container] +Image=localhost/changed-image diff --git a/test/e2e/quadlet_test.go b/test/e2e/quadlet_test.go index ef0057da25..5c57d95752 100644 --- a/test/e2e/quadlet_test.go +++ b/test/e2e/quadlet_test.go @@ -25,6 +25,17 @@ type quadletTestcase struct { checks [][]string } +// Converts "foo@bar.container" to "foo@.container" +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()) @@ -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 @@ -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("template@.container", "template@.container", 0, ""), + Entry("template@instance.container", "template@instance.container", 0, ""), Entry("basic.volume", "basic.volume", 0, ""), Entry("device-copy.volume", "device-copy.volume", 0, ""),