//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 ..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 ..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 }