Skip to content

Commit b25bb53

Browse files
committed
Handle uninstantiated template quadlets
Fixes: #26960 Signed-off-by: Šimon Brauner <sbrauner@redhat.com>
1 parent 641ba7e commit b25bb53

8 files changed

Lines changed: 215 additions & 35 deletions

File tree

cmd/podman/common/completion.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1776,7 +1776,7 @@ func AutocompleteSDNotify(_ *cobra.Command, _ []string, _ string) ([]string, cob
17761776

17771777
var containerStatuses = []string{"created", "running", "paused", "stopped", "exited", "unknown"}
17781778

1779-
var quadletStatuses = []string{"Not loaded", "active/running", "inactive/dead", "failed/failed", "activating/start", "deactivating/stop"}
1779+
var quadletStatuses = []string{entities.QuadletStatusNotLoaded, entities.QuadletStatusLoadedTemplate, "active/running", "inactive/dead", "failed/failed", "activating/start", "deactivating/stop"}
17801780

17811781
// AutocompletePsFilters - Autocomplete ps filter options.
17821782
func AutocompletePsFilters(cmd *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {

docs/source/markdown/podman-quadlet-list.1.md.in

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Supported filters:
2323
| Filter | Description |
2424
|------------|--------------------------------------------------------------------------------------------------|
2525
| name | Filter by quadlet name |
26-
| status | Filter by quadlet status. Valid values: `Not loaded`, `active/running`, `inactive/dead`, `failed/failed`, `activating/start`, `deactivating/stop` |
2726
| pod | Filter by the `Pod=` value (displays only for .container units) |
27+
| status | Filter by quadlet status. Valid values: `Not loaded`, `loaded template`, `active/running`, `inactive/dead`, `failed/failed`, `activating/start`, `deactivating/stop` |
2828

2929
#### **--format**=*format*
3030

@@ -38,7 +38,7 @@ Print results with a Go template.
3838
| .Name | Name of the Quadlet file |
3939
| .Path | Quadlet file path on disk |
4040
| .Pod | Pod quadlet file from `Pod=` in `[Container]` (empty if not set) |
41-
| .Status | Quadlet status corresponding to systemd unit |
41+
| .Status | Quadlet status corresponding to systemd unit (`Not loaded` and `loaded template` are from podman, other values are from systemd) |
4242
| .UnitName | Systemd unit name corresponding to quadlet |
4343

4444
@@option noheading

docs/source/markdown/podman-quadlet-rm.1.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ podman\-quadlet\-rm - Removes an installed quadlet
1111
Remove one or more installed Quadlets from the current user. Following command also takes application name
1212
as input and removes all the Quadlets which belongs to that specific application.
1313

14+
When the argument is uninstantiated template quadlet, this command removes the template quadlet file (e.g. `templateName@.container`) and the generated systemd template unit (e.g. `templateName@.service`). If there are running instances of that systemd template, the command fails if **--force** option is not set, and tries to stop the instances if **--force** option is set.
15+
1416
Note: If a quadlet is part of an application, removing that specific quadlet will remove the entire application.
1517
When a quadlet is installed from a directory, all files installed from that directory—including both quadlet and non-quadlet files—are considered part
1618
of a single application.
@@ -23,7 +25,7 @@ Remove all Quadlets for the current user.
2325

2426
#### **--force**, **-f**
2527

26-
Remove running Quadlets.
28+
Remove running Quadlets (in case of uninstantiated template quadlets, stop its instances).
2729

2830
#### **--ignore**, **-i**
2931

pkg/api/server/register_quadlets.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error {
196196
// name: force
197197
// type: boolean
198198
// default: false
199-
// description: Remove running quadlet by stopping it first
199+
// description: Remove running quadlets (in case of uninstantiated template quadlets, stop its instances).
200200
// - in: query
201201
// name: ignore
202202
// type: boolean

pkg/domain/entities/quadlet.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ type QuadletListOptions struct {
2828
Filters []string
2929
}
3030

31+
const (
32+
// Quadlet does not have a corresponding loaded systemd unit.
33+
QuadletStatusNotLoaded = "Not loaded"
34+
// Quadlet is a template loaded into systemd (there is no status
35+
// to display in the same sense as for concrete units).
36+
QuadletStatusLoadedTemplate = "loaded template"
37+
)
38+
3139
// A ListQuadlet is a single Quadlet to be listed by `podman quadlet list`
3240
type ListQuadlet struct {
3341
// Name is the name of the Quadlet file
@@ -37,8 +45,9 @@ type ListQuadlet struct {
3745
UnitName string
3846
// Path to the Quadlet on disk
3947
Path string
40-
// What is the status of the Quadlet - if present in systemd, will be a
41-
// systemd status, else will mention if the Quadlet has syntax errors
48+
// What is the status of the Quadlet - either values from systemd
49+
// (e.g. active/running), or podman-defined values "Not loaded"
50+
// and "loaded template".
4251
Status string
4352
// If multiple quadlets were installed together they will belong
4453
// to common App.

pkg/domain/infra/abi/quadlet_utils.go

Lines changed: 129 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ func getAllQuadlets(ctx context.Context, conn *dbus.Conn) ([]*entities.ListQuadl
116116
// Get the root paths of all quadlets available to the current user
117117
quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless(), false)
118118

119-
allServiceNames := make([]string, 0)
119+
concreteServiceNames := make([]string, 0)
120+
templateServiceNames := make([]string, 0)
120121

121122
// for every quadlet dir, let's get the quadlets
122123
for _, dir := range quadletDirs {
@@ -154,44 +155,77 @@ func getAllQuadlets(ctx context.Context, conn *dbus.Conn) ([]*entities.ListQuadl
154155
report.Pod = pod
155156
}
156157

157-
allServiceNames = append(allServiceNames, serviceName)
158+
if systemdquadlet.IsTemplateUnitFileName(serviceName) {
159+
templateServiceNames = append(templateServiceNames, serviceName)
160+
} else {
161+
concreteServiceNames = append(concreteServiceNames, serviceName)
162+
}
158163
partialReports[serviceName] = report
159164
}
160165
}
161166

162-
// Get status of all systemd units with given names.
163-
statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames)
164-
if err != nil {
165-
return nil, fmt.Errorf("querying systemd for unit status: %w", err)
166-
}
167-
if len(statuses) != len(allServiceNames) {
168-
logrus.Warnf("Queried for %d services but received %d responses", len(allServiceNames), len(statuses))
167+
// Get status of concrete systemd units with given names.
168+
if len(concreteServiceNames) > 0 {
169+
statuses, err := conn.ListUnitsByNamesContext(ctx, concreteServiceNames)
170+
if err != nil {
171+
return nil, fmt.Errorf("querying systemd for unit status: %w", err)
172+
}
173+
if len(statuses) != len(concreteServiceNames) {
174+
logrus.Warnf("Queried for %d services but received %d responses", len(concreteServiceNames), len(statuses))
175+
}
176+
177+
for _, unitStatus := range statuses {
178+
report, ok := partialReports[unitStatus.Name]
179+
if !ok {
180+
logrus.Errorf("Unexpected unit returned by systemd - was not searching for %s", unitStatus.Name)
181+
}
182+
logrus.Debugf("Unit %s has status %s %s %s", unitStatus.Name, unitStatus.LoadState, unitStatus.ActiveState, unitStatus.SubState)
183+
report.UnitName = unitStatus.Name
184+
185+
// Unit is not loaded
186+
if unitStatus.LoadState != "loaded" {
187+
report.Status = entities.QuadletStatusNotLoaded
188+
} else {
189+
report.Status = fmt.Sprintf("%s/%s", unitStatus.ActiveState, unitStatus.SubState)
190+
}
191+
reports = append(reports, &report)
192+
delete(partialReports, unitStatus.Name)
193+
}
169194
}
170195

171-
for _, unitStatus := range statuses {
172-
report, ok := partialReports[unitStatus.Name]
173-
if !ok {
174-
logrus.Errorf("Unexpected unit returned by systemd - was not searching for %s", unitStatus.Name)
196+
// Uninstantiated template units need to be handled separately, because
197+
// ListUnitsBy* functions do not return anything for templates.
198+
if len(templateServiceNames) > 0 {
199+
unitFiles, err := conn.ListUnitFilesByPatternsContext(ctx, []string{}, templateServiceNames)
200+
if err != nil {
201+
return nil, fmt.Errorf("querying systemd for unit file: %w", err)
175202
}
176-
logrus.Debugf("Unit %s has status %s %s %s", unitStatus.Name, unitStatus.LoadState, unitStatus.ActiveState, unitStatus.SubState)
177-
report.UnitName = unitStatus.Name
178-
179-
// Unit is not loaded
180-
if unitStatus.LoadState != "loaded" {
181-
report.Status = "Not loaded"
182-
} else {
183-
report.Status = fmt.Sprintf("%s/%s", unitStatus.ActiveState, unitStatus.SubState)
203+
unitFilesFound := make(map[string]struct{})
204+
for _, unitFile := range unitFiles {
205+
unitFilesFound[filepath.Base(unitFile.Path)] = struct{}{}
206+
}
207+
for _, templateServiceName := range templateServiceNames {
208+
report := partialReports[templateServiceName]
209+
210+
report.UnitName = templateServiceName
211+
212+
if _, ok := unitFilesFound[templateServiceName]; ok {
213+
report.Status = entities.QuadletStatusLoadedTemplate
214+
} else {
215+
report.Status = entities.QuadletStatusNotLoaded
216+
}
217+
218+
reports = append(reports, &report)
219+
delete(partialReports, templateServiceName)
184220
}
185-
reports = append(reports, &report)
186-
delete(partialReports, unitStatus.Name)
187221
}
188222

189223
// This should not happen.
190224
// Systemd will give us output for everything we sent to them, even if it's not a valid unit.
191225
// We can find them with LoadState, as we do above.
192226
// Handle it anyways because it's easy enough to do.
193227
for _, report := range partialReports {
194-
report.Status = "Not loaded"
228+
report.Status = entities.QuadletStatusNotLoaded
195229
reports = append(reports, &report)
196230
}
197231

@@ -200,7 +234,7 @@ func getAllQuadlets(ctx context.Context, conn *dbus.Conn) ([]*entities.ListQuadl
200234

201235
func removeQuadlet(ctx context.Context, conn *dbus.Conn, quadlet *entities.ListQuadlet, force bool) error {
202236
switch quadlet.Status {
203-
case "Not loaded":
237+
case entities.QuadletStatusNotLoaded:
204238
case "inactive/dead":
205239
// Nothing to do here if it doesn't exist in systemd
206240
break
@@ -220,11 +254,81 @@ func removeQuadlet(ctx context.Context, conn *dbus.Conn, quadlet *entities.ListQ
220254
if stopResult != "done" && stopResult != "skipped" {
221255
return fmt.Errorf("unable to stop quadlet %s: %s", quadlet.Name, stopResult)
222256
}
257+
case entities.QuadletStatusLoadedTemplate:
258+
if err := stopLoadedTemplateInstances(ctx, conn, quadlet, force); err != nil {
259+
return err
260+
}
223261
}
224262

225263
return os.Remove(quadlet.Path)
226264
}
227265

266+
// stopLoadedTemplateInstances handles the running instances of a template.
267+
// If force is set, the instances are stopped and error is returned on failure.
268+
// If force is not set, stopping is refused and error is returned on running instances.
269+
func stopLoadedTemplateInstances(ctx context.Context, conn *dbus.Conn, quadlet *entities.ListQuadlet, force bool) error {
270+
extension := filepath.Ext(quadlet.UnitName)
271+
272+
// The regular expression in this pattern matches the template itself as well, but the
273+
// ListUnitsBy* functions do not return anything for templates.
274+
instancePattern := strings.TrimSuffix(quadlet.UnitName, extension) + "*" + extension
275+
instances, err := conn.ListUnitsByPatternsContext(ctx, []string{}, []string{instancePattern})
276+
if err != nil {
277+
return fmt.Errorf("querying systemd for instances of %q: %w", quadlet.UnitName, err)
278+
}
279+
280+
var runningInstanceErrors []error
281+
for _, instanceStatus := range instances {
282+
properties, err := conn.GetUnitPropertiesContext(ctx, instanceStatus.Name)
283+
if err != nil {
284+
logrus.Warnf("getting unit properties for %q: %v", instanceStatus.Name, err)
285+
continue
286+
}
287+
288+
sourcePath, ok := properties["SourcePath"].(string)
289+
if !ok || sourcePath == "" {
290+
logrus.Warnf("source path not found for unit %q", instanceStatus.Name)
291+
continue
292+
}
293+
294+
if filepath.Clean(sourcePath) != filepath.Clean(quadlet.Path) {
295+
// Nothing to do here if the unit is not associated with this quadlet
296+
continue
297+
}
298+
299+
if instanceStatus.LoadState != "loaded" {
300+
// Nothing to do here if the instance is not loaded
301+
continue
302+
}
303+
304+
if instanceStatus.ActiveState == "active" {
305+
if !force {
306+
runningInstanceErrors = append(runningInstanceErrors, fmt.Errorf("template %q has running instance %q and force is not set, refusing to remove: %w", quadlet.Name, instanceStatus.Name, define.ErrQuadletRunning))
307+
continue
308+
}
309+
logrus.Debugf("Going to stop systemd unit %q (Instance of template %q)", instanceStatus.Name, quadlet.Name)
310+
311+
ch := make(chan string)
312+
if _, err := conn.StopUnitContext(ctx, instanceStatus.Name, "replace", ch); err != nil {
313+
runningInstanceErrors = append(runningInstanceErrors, fmt.Errorf("stopping instance %q of template %q: %w", instanceStatus.Name, quadlet.Name, err))
314+
continue
315+
}
316+
317+
logrus.Debugf("Waiting for systemd unit %q to stop", instanceStatus.Name)
318+
stopResult := <-ch
319+
if stopResult != "done" && stopResult != "skipped" {
320+
runningInstanceErrors = append(runningInstanceErrors, fmt.Errorf("unable to stop instance %q of template %q: %q", instanceStatus.Name, quadlet.Name, stopResult))
321+
}
322+
}
323+
}
324+
325+
if len(runningInstanceErrors) > 0 {
326+
return errors.Join(runningInstanceErrors...)
327+
}
328+
329+
return nil
330+
}
331+
228332
// getQuadletServiceNameAndUnit parses a Quadlet file and returns both the
229333
// generated systemd service name and the parsed unit file.
230334
func getQuadletServiceNameAndUnit(quadletPath string) (string, *parser.UnitFile, error) {

pkg/systemd/quadlet/quadlet.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -940,8 +940,9 @@ func ConvertContainer(container *parser.UnitFile, unitsInfoMap map[string]*UnitI
940940
return service, warnings, nil
941941
}
942942

943-
func isTemplateUnit(unit *parser.UnitFile) bool {
944-
base := strings.TrimSuffix(unit.Filename, filepath.Ext(unit.Filename))
943+
// Determine if the given file name belongs to an uninstantiated template unit.
944+
func IsTemplateUnitFileName(fileName string) bool {
945+
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
945946
return strings.HasSuffix(base, "@")
946947
}
947948

@@ -2228,7 +2229,7 @@ func handlePod(quadletUnitFile, serviceUnitFile *parser.UnitFile, groupName stri
22282229
// If we want to start the container with the pod, we add it to this list.
22292230
// This creates corresponding Wants=/Before= statements in the pod service.
22302231
// Do not add this for template units as dependency cannot be created for them.
2231-
if !isTemplateUnit(quadletUnitFile) && quadletUnitFile.LookupBooleanWithDefault(groupName, KeyStartWithPod, true) {
2232+
if !IsTemplateUnitFileName(quadletUnitFile.Filename) && quadletUnitFile.LookupBooleanWithDefault(groupName, KeyStartWithPod, true) {
22322233
podInfo.ContainersToStart = append(podInfo.ContainersToStart, serviceUnitFile.Filename)
22332234
}
22342235
}

test/system/253-podman-quadlet.bats

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,70 @@ EOF
156156
run_podman quadlet rm $ctr_unit
157157
}
158158

159+
@test "quadlet verb - install, list, print, rm - template" {
160+
# Determine the install directory path based on rootless/root
161+
local install_dir
162+
install_dir=$(get_quadlet_install_dir)
163+
# Create a test quadlet file
164+
local quadlet_name_without_extension="templated-quadlet@"
165+
local quadlet_name=${quadlet_name_without_extension}.container
166+
local quadlet_unit_name=${quadlet_name_without_extension}.service
167+
local quadlet_instance_name=${quadlet_name_without_extension}instance.service
168+
local quadlet_file=$PODMAN_TMPDIR/$quadlet_name
169+
cat > "$quadlet_file" <<EOF
170+
[Container]
171+
Image=$IMAGE
172+
Exec=sh -c "echo STARTED CONTAINER; trap 'exit' SIGTERM; while :; do sleep 0.1; done"
173+
EOF
174+
# Test quadlet install
175+
run_podman quadlet install "$quadlet_file"
176+
assert "$output" =~ "$quadlet_name" "install output should contain quadlet name"
177+
178+
# Test quadlet list
179+
run_podman quadlet list --filter status='loaded template'
180+
assert "$output" =~ "$quadlet_name" "list should contain $quadlet_name"
181+
assert "$output" =~ "$quadlet_unit_name" "UNIT NAME should be $quadlet_unit_name"
182+
assert "$output" =~ "loaded template" "STATUS should be 'loaded template'"
183+
assert "$output" =~ "$install_dir/$quadlet_name" "PATH ON DISK should show the quadlet file path"
184+
185+
# Test quadlet print
186+
run_podman quadlet print "$quadlet_name"
187+
assert "$output" == "$(<"$quadlet_file")" "print output matches quadlet file"
188+
189+
# Regenerate the service manually, otherwise PODMAN path is not set correctly in CI
190+
QUADLET_UNIT_DIRS="$install_dir" run \
191+
timeout --foreground -v --kill=10 $PODMAN_TIMEOUT \
192+
$QUADLET $_DASHUSER $UNIT_DIR
193+
assert $status -eq 0 "Failed to regenerate the service manually"
194+
systemctl daemon-reload
195+
196+
# Instantiate the template
197+
systemctl_start "$quadlet_instance_name"
198+
199+
# Test quadlet rm without --force (should fail)
200+
run_podman 125 quadlet rm "$quadlet_name"
201+
assert "$output" =~ "$quadlet_instance_name" "error message should contain running instance name"
202+
assert "$output" =~ "quadlet is running" "error message should contain the explanation"
203+
# Verify template was not removed
204+
run_podman quadlet list
205+
assert "$output" =~ "$quadlet_name" "list should contain template"
206+
207+
# Test quadlet rm with --force (should succeed)
208+
run_podman quadlet rm --force "$quadlet_name"
209+
assert "$output" =~ "$quadlet_name" "remove output should contain quadlet name"
210+
# Verify template was removed
211+
run_podman quadlet list
212+
assert "$output" !~ "$quadlet_name" "list should not contain removed template"
213+
214+
# Check that removing template also works without --force when there are no running instances
215+
run_podman quadlet install "$quadlet_file"
216+
assert "$output" =~ "$quadlet_name" "install output should contain quadlet name"
217+
run_podman quadlet rm "$quadlet_name"
218+
assert "$output" =~ "$quadlet_name" "remove output should contain quadlet name"
219+
# Verify template was removed
220+
run_podman quadlet list
221+
assert "$output" !~ "$quadlet_name" "list should not contain removed template"
222+
}
159223

160224
@test "quadlet verb - install multiple files from directory and remove by app name" {
161225
# Create a directory for multiple quadlet files

0 commit comments

Comments
 (0)