podman-build/pkg/domain/infra/abi/quadlet.go
2025-10-11 12:30:35 +09:00

763 lines
25 KiB
Go

//go:build !remote
package abi
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/containers/common/pkg/config"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/rootless"
"github.com/containers/podman/v5/pkg/systemd"
"github.com/containers/podman/v5/pkg/systemd/parser"
systemdquadlet "github.com/containers/podman/v5/pkg/systemd/quadlet"
"github.com/containers/podman/v5/pkg/util"
"github.com/containers/storage/pkg/fileutils"
"github.com/sirupsen/logrus"
)
// deleteAsset reads .<name>.asset, deletes listed files, then deletes the asset file
func deleteAsset(name string) error {
assetFilename := fmt.Sprintf(".%s.asset", name)
installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())
assetFilePath := filepath.Join(installDir, assetFilename)
result, err := getAssetListFromFile(assetFilePath)
if err != nil {
return fmt.Errorf("unable to get list of files to delete: %w", err)
}
for _, entry := range result {
err = os.Remove(filepath.Join(installDir, entry))
if err != nil {
return fmt.Errorf("unable to delete %s: %w", filepath.Join(installDir, entry), err)
}
}
err = os.Remove(assetFilePath)
if err != nil {
return fmt.Errorf("unable to delete %s: %w", assetFilePath, err)
}
return err
}
// readLinesFromFile reads lines from a file and calls the provided callback for each non-empty line.
// It handles file opening, scanning, trimming whitespace, and error checking.
func readLinesFromFile(filePath string, callback func(line string) error) error {
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("could not open file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if err := callback(line); err != nil {
return err
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading file: %w", err)
}
return nil
}
func getAssetListFromFile(path string) ([]string, error) {
var result []string
err := readLinesFromFile(path, func(line string) error {
if strings.Contains(line, "/") {
logrus.Warnf("Unexpected file line %q, expected name but got path components", line)
return nil
}
result = append(result, line)
return nil
})
if err != nil {
return result, fmt.Errorf("error reading asset file: %w", err)
}
return result, nil
}
// Install one or more Quadlet files
func (ic *ContainerEngine) QuadletInstall(ctx context.Context, pathsOrURLs []string, options entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) {
// Is systemd available to the current user?
// We cannot proceed if not.
conn, err := systemd.ConnectToDBUS()
if err != nil {
return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
}
defer conn.Close()
cfg, err := config.Default()
if err != nil {
return nil, fmt.Errorf("unable to load default config: %w", err)
}
// Is Quadlet installed? No point if not.
quadletPath, err := cfg.FindHelperBinary("quadlet", true)
if err != nil {
return nil, fmt.Errorf("cannot stat Quadlet generator, Quadlet may not be installed: %w", err)
}
if quadletPath == "" {
return nil, fmt.Errorf("unable to find `quadlet` binary, Quadlet may not be installed")
}
quadletStat, err := os.Stat(quadletPath)
if err != nil {
return nil, fmt.Errorf("cannot stat Quadlet generator, Quadlet may not be installed: %w", err)
}
if !quadletStat.Mode().IsRegular() || quadletStat.Mode()&0100 == 0 {
return nil, fmt.Errorf("no valid Quadlet binary installed to %q, unable to use Quadlet", quadletPath)
}
installDir := systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless())
logrus.Debugf("Going to install Quadlet to directory %s", installDir)
if err := os.MkdirAll(installDir, 0755); err != nil {
return nil, fmt.Errorf("unable to create Quadlet install path %s: %w", installDir, err)
}
installReport := entities.QuadletInstallReport{
InstalledQuadlets: make(map[string]string),
QuadletErrors: make(map[string]error),
}
assetFile := ""
paths := pathsOrURLs
if len(pathsOrURLs) > 0 && !strings.HasPrefix(pathsOrURLs[0], "http://") && !strings.HasPrefix(pathsOrURLs[0], "https://") {
// Check if first path is dir, this is an APP
info, err := os.Stat(pathsOrURLs[0])
if err != nil {
return nil, fmt.Errorf("unable to stat Quadlet path %s: %w", pathsOrURLs[0], err)
}
if info.IsDir() {
// If it's a directory, then read all files and add it to paths
entries, err := os.ReadDir(pathsOrURLs[0])
if err != nil {
return nil, fmt.Errorf("unable to read Quadlet dir %s: %w", pathsOrURLs[0], err)
}
redoPaths := make([]string, 0, len(entries))
for _, entry := range entries {
redoPaths = append(redoPaths, filepath.Join(pathsOrURLs[0], entry.Name()))
}
redoPaths = append(redoPaths, pathsOrURLs[1:]...)
paths = redoPaths
// treat all file in this session as part of one app.
assetFile = "." + filepath.Base(pathsOrURLs[0]) + ".app"
}
}
// Loop over all given URLs
for _, toInstall := range paths {
validateQuadletFile := false
if assetFile == "" {
assetFile = "." + filepath.Base(toInstall) + ".asset"
validateQuadletFile = true
}
switch {
case strings.HasPrefix(toInstall, "http://") || strings.HasPrefix(toInstall, "https://"):
r, err := http.Get(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to download URL %s: %w", toInstall, err)
continue
}
defer r.Body.Close()
quadletFileName, err := getFileName(r, toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to get file name from url %s: %w", toInstall, err)
continue
}
// It's a URL. Pull to temporary file.
tmpFile, err := os.CreateTemp("", quadletFileName)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("unable to create temporary file to download URL %s: %w", toInstall, err)
continue
}
defer func() {
tmpFile.Close()
if err := os.Remove(tmpFile.Name()); err != nil {
logrus.Errorf("unable to remove temporary file %q: %v", tmpFile.Name(), err)
}
}()
_, err = io.Copy(tmpFile, r.Body)
if err != nil {
installReport.QuadletErrors[toInstall] = fmt.Errorf("populating temporary file: %w", err)
continue
}
installedPath, err := ic.installQuadlet(ctx, tmpFile.Name(), quadletFileName, installDir, assetFile, validateQuadletFile)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
default:
err := fileutils.Exists(toInstall)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
// If toInstall is a single file, execute the original logic
installedPath, err := ic.installQuadlet(ctx, toInstall, "", installDir, assetFile, validateQuadletFile)
if err != nil {
installReport.QuadletErrors[toInstall] = err
continue
}
installReport.InstalledQuadlets[toInstall] = installedPath
}
}
// TODO: Should we still do this if the above validation errored?
if options.ReloadSystemd {
if err := conn.ReloadContext(ctx); err != nil {
return &installReport, fmt.Errorf("reloading systemd: %w", err)
}
}
return &installReport, nil
}
// Extracts file name from Content-Disposition or URL
func getFileName(resp *http.Response, fileURL string) (string, error) {
// Try to get filename from Content-Disposition header
cd := resp.Header.Get("Content-Disposition")
if cd != "" {
const prefix = "filename="
if idx := strings.Index(cd, prefix); idx != -1 {
filename := cd[idx+len(prefix):]
filename = strings.Trim(filename, "\"'")
return filename, nil
}
}
// Fallback: get filename from URL path
u, err := url.Parse(fileURL)
if err != nil {
return "", err
}
return path.Base(u.Path), nil
}
// Install a single Quadlet from a path on local disk to the given install directory.
// Perform some minimal validation, but not much.
// We can't know about a lot of problems without running the Quadlet binary, which we
// only want to do once.
func (ic *ContainerEngine) installQuadlet(_ context.Context, path, destName, installDir, assetFile string, isQuadletFile bool) (string, error) {
// First, validate that the source path exists and is a file
stat, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("quadlet to install %q does not exist or cannot be read: %w", path, err)
}
if stat.IsDir() {
return "", fmt.Errorf("quadlet to install %q is not a file", path)
}
finalPath := filepath.Join(installDir, filepath.Base(filepath.Clean(path)))
if destName != "" {
finalPath = filepath.Join(installDir, destName)
}
// Validate extension is valid
if isQuadletFile && !systemdquadlet.IsExtSupported(finalPath) {
return "", fmt.Errorf("%q is not a supported Quadlet file type", filepath.Ext(finalPath))
}
file, err := os.OpenFile(finalPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
if err != nil {
if errors.Is(err, fs.ErrExist) {
return "", fmt.Errorf("a Quadlet with name %s already exists, refusing to overwrite", filepath.Base(finalPath))
}
return "", fmt.Errorf("unable to open file %s: %w", filepath.Base(finalPath), err)
}
defer file.Close()
// Move the file in
srcFile, err := os.Open(path)
if err != nil {
return "", fmt.Errorf("unable to open file: %w", err)
}
defer srcFile.Close()
err = fileutils.ReflinkOrCopy(srcFile, file)
if err != nil {
return "", fmt.Errorf("unable to copy file from %s to %s: %w", path, finalPath, err)
}
// When we install files using this function, caller of this function can turn off `validateQuadletFile`
// when they are installing `non-quadlet` files.
if !isQuadletFile {
err := appendStringToFile(filepath.Join(installDir, assetFile), filepath.Base(filepath.Clean(path)))
if err != nil {
return "", fmt.Errorf("error while writing non-quadlet filename: %w", err)
}
}
return finalPath, nil
}
// appendStringToFile appends the given text to the specified file.
// If the file does not exist, it will be created with 0644 permissions.
func appendStringToFile(filePath, text string) error {
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(text + "\n")
return err
}
// buildAppMap scans the given directory for files that start with '.'
// and end with '.app', reads their contents (one filename per line), and
// returns a map where each filename maps to the .app file that contains it.
// Also returns a map where each `.app` points to a slice of strings containing
// all the files in that `.app`.
func buildAppMap(dir string) (map[string]string, map[string][]string, error) {
reverseMap := make(map[string]string)
appMap := make(map[string][]string)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
logrus.Warnf("Error descending into path %s: %v", path, err)
}
return filepath.SkipDir
}
info, err := d.Info()
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
logrus.Warnf("Error descending into path %s: %v", path, err)
}
return filepath.SkipDir
}
if !info.IsDir() {
name := info.Name()
if strings.HasPrefix(name, ".") && strings.HasSuffix(name, ".app") {
err := readLinesFromFile(path, func(line string) error {
reverseMap[line] = name
appMap[name] = append(appMap[name], line)
return nil
})
if err != nil {
return err
}
}
}
return nil
})
if err != nil {
return nil, nil, err
}
return reverseMap, appMap, nil
}
// Get the paths of all quadlets available to the current user
func getAllQuadletPaths() []string {
var quadletPaths []string
quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless())
for _, dir := range quadletDirs {
dents, err := os.ReadDir(dir)
if err != nil {
if !errors.Is(err, fs.ErrNotExist) {
// This is perfectly normal, some quadlet directories aren't created by the package
logrus.Warnf("Cannot list Quadlet directory %s: %v", dir, err)
}
continue
}
logrus.Debugf("Checking for quadlets in %q", dir)
for _, dent := range dents {
if systemdquadlet.IsExtSupported(dent.Name()) && !dent.IsDir() {
logrus.Debugf("Found quadlet %q", dent.Name())
quadletPaths = append(quadletPaths, filepath.Join(dir, dent.Name()))
}
}
}
return quadletPaths
}
// Generate systemd service name for a Quadlet from full path to the Quadlet file
func getQuadletServiceName(quadletPath string) (string, error) {
unit, err := parser.ParseUnitFile(quadletPath)
if err != nil {
return "", fmt.Errorf("parsing Quadlet file %s: %w", quadletPath, err)
}
serviceName, err := systemdquadlet.GetUnitServiceName(unit)
if err != nil {
return "", fmt.Errorf("generating service name for Quadlet %s: %w", filepath.Base(quadletPath), err)
}
return serviceName + ".service", nil
}
type QuadletFilter func(q *entities.ListQuadlet) bool
func generateQuadletFilter(filter string, filterValues []string) (func(q *entities.ListQuadlet) bool, error) {
switch filter {
case "name":
return func(q *entities.ListQuadlet) bool {
res := util.StringMatchRegexSlice(q.Name, filterValues)
return res
}, nil
default:
return nil, fmt.Errorf("%s is not a valid filter", filter)
}
}
func (ic *ContainerEngine) QuadletList(ctx context.Context, options entities.QuadletListOptions) ([]*entities.ListQuadlet, error) {
reverseMap, _, err := buildAppMap(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()))
if err != nil {
return nil, fmt.Errorf("unable to build app map: %w", err)
}
// Is systemd available to the current user?
// We cannot proceed if not.
conn, err := systemd.ConnectToDBUS()
if err != nil {
return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
}
defer conn.Close()
quadletPaths := getAllQuadletPaths()
// Create filter functions
filterFuncs := make([]func(q *entities.ListQuadlet) bool, 0, len(options.Filters))
filterMap := make(map[string][]string)
// TODO: Add filter for app names.
for _, f := range options.Filters {
fname, filter, hasFilter := strings.Cut(f, "=")
if !hasFilter {
return nil, fmt.Errorf("invalid filter %q", f)
}
filterMap[fname] = append(filterMap[fname], filter)
}
for fname, filter := range filterMap {
filterFunc, err := generateQuadletFilter(fname, filter)
if err != nil {
return nil, err
}
filterFuncs = append(filterFuncs, filterFunc)
}
reports := make([]*entities.ListQuadlet, 0, len(quadletPaths))
allServiceNames := make([]string, 0, len(quadletPaths))
partialReports := make(map[string]entities.ListQuadlet)
for _, path := range quadletPaths {
appName := ""
value, ok := reverseMap[filepath.Base(path)]
if ok {
appName = value
}
report := entities.ListQuadlet{
Name: filepath.Base(path),
Path: path,
App: appName,
}
serviceName, err := getQuadletServiceName(path)
if err != nil {
report.Status = err.Error()
reports = append(reports, &report)
continue
}
partialReports[serviceName] = report
allServiceNames = append(allServiceNames, serviceName)
}
// Get status of all systemd units with given names.
statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames)
if err != nil {
return nil, fmt.Errorf("querying systemd for unit status: %w", err)
}
if len(statuses) != len(allServiceNames) {
logrus.Warnf("Queried for %d services but received %d responses", len(allServiceNames), len(statuses))
}
for _, unitStatus := range statuses {
report, ok := partialReports[unitStatus.Name]
if !ok {
logrus.Errorf("Unexpected unit returned by systemd - was not searching for %s", unitStatus.Name)
}
logrus.Debugf("Unit %s has status %s %s %s", unitStatus.Name, unitStatus.LoadState, unitStatus.ActiveState, unitStatus.SubState)
report.UnitName = unitStatus.Name
// Unit is not loaded
if unitStatus.LoadState != "loaded" {
report.Status = "Not loaded"
} else {
report.Status = fmt.Sprintf("%s/%s", unitStatus.ActiveState, unitStatus.SubState)
}
reports = append(reports, &report)
delete(partialReports, unitStatus.Name)
}
// This should not happen.
// Systemd will give us output for everything we sent to them, even if it's not a valid unit.
// We can find them with LoadState, as we do above.
// Handle it anyways because it's easy enough to do.
for _, report := range partialReports {
report.Status = "Not loaded"
reports = append(reports, &report)
}
finalReports := make([]*entities.ListQuadlet, 0, len(reports))
for _, report := range reports {
include := true
for _, filterFunc := range filterFuncs {
include = filterFunc(report)
}
if include {
finalReports = append(finalReports, report)
}
}
return finalReports, nil
}
// Retrieve path to a Quadlet file given full name including extension
func getQuadletPathByName(name string) (string, error) {
// Check if we were given a valid extension
if !systemdquadlet.IsExtSupported(name) {
return "", fmt.Errorf("%q is not a supported quadlet file type", filepath.Ext(name))
}
quadletDirs := systemdquadlet.GetUnitDirs(rootless.IsRootless())
for _, dir := range quadletDirs {
testPath := filepath.Join(dir, name)
if _, err := os.Stat(testPath); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
return "", fmt.Errorf("cannot stat quadlet at path %q: %w", testPath, err)
}
continue
}
return testPath, nil
}
return "", fmt.Errorf("could not locate quadlet %q in any supported quadlet directory", name)
}
func (ic *ContainerEngine) QuadletPrint(ctx context.Context, quadlet string) (string, error) {
quadletPath, err := getQuadletPathByName(quadlet)
if err != nil {
return "", err
}
contents, err := os.ReadFile(quadletPath)
if err != nil {
return "", fmt.Errorf("reading quadlet %q contents: %w", quadletPath, err)
}
return string(contents), nil
}
// QuadletRemove removes one or more Quadlet files or applications and reloads systemd daemon as needed. The function returns a `QuadletRemoveReport`
// containing the removal status for each quadlet file or application, or returns an error if the entire function fails.
func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, options entities.QuadletRemoveOptions) (*entities.QuadletRemoveReport, error) {
report := entities.QuadletRemoveReport{
Errors: make(map[string]error),
Removed: []string{},
}
removeList := []string{}
reverseMap, appMap, err := buildAppMap(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()))
if err != nil {
return nil, fmt.Errorf("unable to build app map: %w", err)
}
expandQuadletList := []string{}
// Process all `.app` files in arguments, if `.app` file
// is found then expand it to its respective quadlet files
// and remove it from the processing list.
for _, quadlet := range quadlets {
// Most likely this is an app
if strings.HasPrefix(quadlet, ".") && strings.HasSuffix(quadlet, ".app") {
files, ok := appMap[quadlet]
// Add all files of this application in to-be removed list.
if ok {
for _, file := range files {
if !systemdquadlet.IsExtSupported(file) {
removeList = append(removeList, file)
} else {
expandQuadletList = append(expandQuadletList, file)
}
}
}
// also add .app file itself to the remove list so it can
// be cleaned after removal of all components in the list
if !slices.Contains(removeList, quadlet) {
removeList = append(removeList, quadlet)
}
} else {
expandQuadletList = append(expandQuadletList, quadlet)
}
}
quadlets = expandQuadletList
allQuadletPaths := make([]string, 0, len(quadlets))
allServiceNames := make([]string, 0, len(quadlets))
runningQuadlets := make([]string, 0, len(quadlets))
serviceNameToQuadletName := make(map[string]string)
needReload := options.ReloadSystemd
if len(quadlets) == 0 && !options.All {
return nil, errors.New("must provide at least 1 quadlet to remove")
}
// Is systemd available to the current user?
// We cannot proceed if not.
conn, err := systemd.ConnectToDBUS()
if err != nil {
return nil, fmt.Errorf("connecting to systemd dbus: %w", err)
}
defer conn.Close()
if options.All {
allQuadlets := getAllQuadletPaths()
quadlets = allQuadlets
}
// We are using index wise iteration here instead of `range`
// because we are modifying `quadlets` in this loop by appending
// more elements to it if needed, we cannot do this with `range`.
for i := 0; i < len(quadlets); i++ {
var err error
var quadletPath string
quadlet := quadlets[i]
if options.All {
quadletPath = quadlet
} else {
quadletPath, err = getQuadletPathByName(quadlet)
}
if !options.All && err != nil {
// All implies Ignore, because the only reason we'd see an error here with all
// is if the quadlet was removed in a TOCTOU scenario.
if options.Ignore {
report.Removed = append(report.Removed, quadlet)
} else {
report.Errors[quadlet] = err
}
continue
}
value, ok := reverseMap[quadlet]
if ok {
// If this is part of app and we are cleaning entire .app
// make sure to add .app file itself to the removal list
// if it does not already exists.
if !slices.Contains(removeList, value) {
removeList = append(removeList, value)
}
appFilePath := filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), value)
filesToRemove, err := getAssetListFromFile(appFilePath)
if err != nil {
return nil, fmt.Errorf("unable to get list of files to remove: %w", err)
}
for _, entry := range filesToRemove {
if !systemdquadlet.IsExtSupported(entry) {
removeList = append(removeList, entry)
if !slices.Contains(removeList, value) {
// In the last also clean .<quadlet>.app file
removeList = append(removeList, value)
}
continue
}
if !slices.Contains(quadlets, entry) {
quadlets = append(quadlets, entry)
}
}
}
allQuadletPaths = append(allQuadletPaths, quadletPath)
serviceName, err := getQuadletServiceName(quadletPath)
if err != nil {
report.Errors[quadlet] = err
continue
}
allServiceNames = append(allServiceNames, serviceName)
serviceNameToQuadletName[serviceName] = quadlet
}
if len(allServiceNames) != 0 {
// Check if units are loaded into systemd, and further if they are running.
// If running and force is not set, error.
// If force is set, try and stop the unit.
statuses, err := conn.ListUnitsByNamesContext(ctx, allServiceNames)
if err != nil {
return nil, fmt.Errorf("querying systemd for unit status: %w", err)
}
for _, unitStatus := range statuses {
quadletName := serviceNameToQuadletName[unitStatus.Name]
if unitStatus.LoadState != "loaded" {
// Nothing to do here if it doesn't exist in systemd
continue
}
needReload = options.ReloadSystemd
if unitStatus.ActiveState == "active" {
if !options.Force {
report.Errors[quadletName] = fmt.Errorf("quadlet %s is running and force is not set, refusing to remove", quadletName)
runningQuadlets = append(runningQuadlets, quadletName)
continue
}
logrus.Infof("Going to stop systemd unit %s (Quadlet %s)", unitStatus.Name, quadletName)
ch := make(chan string)
if _, err := conn.StopUnitContext(ctx, unitStatus.Name, "replace", ch); err != nil {
report.Errors[quadletName] = fmt.Errorf("stopping quadlet %s: %w", quadletName, err)
runningQuadlets = append(runningQuadlets, quadletName)
continue
}
logrus.Debugf("Waiting for systemd unit %s to stop", unitStatus.Name)
stopResult := <-ch
if stopResult != "done" && stopResult != "skipped" {
report.Errors[quadletName] = fmt.Errorf("unable to stop quadlet %s: %s", quadletName, stopResult)
runningQuadlets = append(runningQuadlets, quadletName)
continue
}
}
}
}
// Remove the actual files behind the quadlets
if len(allQuadletPaths) != 0 {
for _, path := range allQuadletPaths {
var errAsset error
quadletName := filepath.Base(path)
errAsset = deleteAsset(quadletName)
if slices.Contains(runningQuadlets, quadletName) {
continue
}
if err := os.Remove(path); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
reportErr := fmt.Errorf("removing quadlet %s: %w", quadletName, err)
if errAsset != nil {
reportErr = errors.Join(reportErr, errAsset)
}
report.Errors[quadletName] = reportErr
continue
}
}
for _, entry := range removeList {
os.Remove(filepath.Join(systemdquadlet.GetInstallUnitDirPath(rootless.IsRootless()), entry))
}
report.Removed = append(report.Removed, quadletName)
}
}
// Reload systemd, if necessary/requested.
if needReload {
if err := conn.ReloadContext(ctx); err != nil {
return &report, fmt.Errorf("reloading systemd: %w", err)
}
}
return &report, nil
}