From 01e0db293172be5220b90a0b7e0434fc91a010d3 Mon Sep 17 00:00:00 2001 From: Nicola Sella Date: Wed, 12 Nov 2025 16:15:07 +0100 Subject: [PATCH 1/4] Add GET /quadlets/{name}/file Fixes: https://issues.redhat.com/browse/RUN-3716 Signed-off-by: Nicola Sella --- pkg/api/handlers/libpod/quadlets.go | 21 ++++++++++ pkg/api/handlers/swagger/errors.go | 7 ++++ pkg/api/handlers/swagger/responses.go | 7 ++++ pkg/api/server/register_quadlets.go | 22 +++++++++++ test/apiv2/36-quadlets.at | 56 +++++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 3 deletions(-) diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index 861e26e297..6f87b67a8b 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -12,6 +12,7 @@ import ( "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/domain/infra/abi" "github.com/containers/podman/v5/pkg/util" + "github.com/sirupsen/logrus" ) func ListQuadlets(w http.ResponseWriter, r *http.Request) { @@ -35,3 +36,23 @@ func ListQuadlets(w http.ResponseWriter, r *http.Request) { utils.WriteResponse(w, http.StatusOK, quadlets) } + +func GetQuadletPrint(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + name := utils.GetName(r) + + containerEngine := abi.ContainerEngine{Libpod: runtime} + + quadletContents, err := containerEngine.QuadletPrint(r.Context(), name) + if err != nil { + utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s: %w", name, err)) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(quadletContents)); err != nil { + logrus.Errorf("Failed to write quadlet contents: %v", err) + return + } +} diff --git a/pkg/api/handlers/swagger/errors.go b/pkg/api/handlers/swagger/errors.go index 69522900e2..a9eeec6704 100644 --- a/pkg/api/handlers/swagger/errors.go +++ b/pkg/api/handlers/swagger/errors.go @@ -79,6 +79,13 @@ type podNotFound struct { Body errorhandling.ErrorModel } +// No such quadlet +// swagger:response +type quadletNotFound struct { + // in:body + Body errorhandling.ErrorModel +} + // No such manifest // swagger:response type manifestNotFound struct { diff --git a/pkg/api/handlers/swagger/responses.go b/pkg/api/handlers/swagger/responses.go index 5ec60bc2dd..87e7b2d7e0 100644 --- a/pkg/api/handlers/swagger/responses.go +++ b/pkg/api/handlers/swagger/responses.go @@ -527,3 +527,10 @@ type quadletListResponse struct { // in:body Body []entities.ListQuadlet } + +// Quadlet file +// swagger:response +type quadletFileResponse struct { + // in:body + Body string +} diff --git a/pkg/api/server/register_quadlets.go b/pkg/api/server/register_quadlets.go index d923fe3544..dd9051a572 100644 --- a/pkg/api/server/register_quadlets.go +++ b/pkg/api/server/register_quadlets.go @@ -32,5 +32,27 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/internalError" r.HandleFunc(VersionedPath("/libpod/quadlets/json"), s.APIHandler(libpod.ListQuadlets)).Methods(http.MethodGet) + // swagger:operation GET /libpod/quadlets/{name}/file libpod QuadletFileLibpod + // --- + // tags: + // - quadlets + // summary: Get quadlet file + // description: Get the contents of a Quadlet, displaying the file including all comments + // produces: + // - text/plain + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name of the quadlet with extension (e.g., "myapp.container") + // responses: + // 200: + // $ref: "#/responses/quadletFileResponse" + // 404: + // $ref: "#/responses/quadletNotFound" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/quadlets/{name}/file"), s.APIHandler(libpod.GetQuadletPrint)).Methods(http.MethodGet) return nil } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 0355975ea7..2410bf566e 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -6,10 +6,60 @@ # NOTE: Once podman-remote quadlet support is added we can enable the podman quadlet tests in # test/system/253-podman-quadlet.bats which should cover it in more detail then. -## list volume +## Test list endpoint t GET libpod/quadlets/json 200 -# Example with filter applied (uncomment once needed) -# t GET libpod/quadlets/json?filters='{"name":["name.*"]}' 200 +# Test 404 for non-existent quadlet +t GET libpod/quadlets/nonexistent.container 404 + +# Install a quadlet with a unique name +quadlet_name=quadlet-test-$(cat /proc/sys/kernel/random/uuid) + +quadlet_container_name="$quadlet_name.container" +quadlet_build_name="$quadlet_name.build" + +TMPDIR=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX) + +quadlet_container_file_content=$(cat << EOF +[Container] +Image=$IMAGE +EOF +) + +quadlet_build_file_content=$(cat << EOF +[Build] +ImageTag=localhost/$quadlet_name +EOF +) + +echo "$quadlet_container_file_content" > $TMPDIR/$quadlet_container_name +echo "$quadlet_build_file_content" > $TMPDIR/$quadlet_build_name + +# this should ensure the .config/containers/systemd directory is created +podman quadlet install $TMPDIR/$quadlet_container_name +podman quadlet install $TMPDIR/$quadlet_build_name + +filter_param=$(printf '{"name":["%s"]}' "$quadlet_name") +t GET "libpod/quadlets/json?filters=$filter_param" 200 \ + length=2 \ + .[0].Name="$quadlet_build_name" \ + .[1].Name="$quadlet_container_name" + +filter_param=$(printf '{"name":["%s"]}' "$quadlet_container_name") +t GET "libpod/quadlets/json?filters=$filter_param" 200 \ + length=1 \ + .[0].Name="$quadlet_container_name" + +t GET "libpod/quadlets/$quadlet_name/file" 404 + +t GET "libpod/quadlets/$quadlet_container_name/file" 200 +is "$output" "$quadlet_container_file_content" + +t GET "libpod/quadlets/$quadlet_build_name/file" 200 +is "$output" "$quadlet_build_file_content" + +podman quadlet rm $quadlet_container_name +podman quadlet rm $quadlet_build_name +rm -rf $TMPDIR # vim: filetype=sh From f2fcc7f09cf4dab10585466d87102ef86dd5d0cd Mon Sep 17 00:00:00 2001 From: Nicola Sella Date: Wed, 19 Nov 2025 17:53:19 +0100 Subject: [PATCH 2/4] Add POST /libpod/quadlets Fixes: https://issues.redhat.com/browse/RUN-3743 Signed-off-by: Nicola Sella --- hack/swagger-check | 3 + pkg/api/handlers/compat/images_build.go | 31 +-- pkg/api/handlers/libpod/quadlets.go | 182 +++++++++++++++++ pkg/api/handlers/utils/handler.go | 29 +++ pkg/api/server/register_quadlets.go | 55 ++++++ test/apiv2/36-quadlets.at | 252 +++++++++++++++++++++++- 6 files changed, 516 insertions(+), 36 deletions(-) diff --git a/hack/swagger-check b/hack/swagger-check index b4481f5bbc..ac88ccab4d 100755 --- a/hack/swagger-check +++ b/hack/swagger-check @@ -323,6 +323,9 @@ sub operation_name { elsif ($action eq "delete" && $endpoint eq "/libpod/play/kube") { $action = "KubeDown" } + elsif ($action eq "list" && $endpoint eq "/libpod/quadlets") { + $action = "Install" + } # Grrrrrr, this one is annoying: some operations get an extra 'All' elsif ($action =~ /^(delete|get|stats)$/ && $endpoint !~ /\{/) { $action .= "All"; diff --git a/pkg/api/handlers/compat/images_build.go b/pkg/api/handlers/compat/images_build.go index 73cd14ee57..5cf0ae0171 100644 --- a/pkg/api/handlers/compat/images_build.go +++ b/pkg/api/handlers/compat/images_build.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "mime" "net/http" "net/url" "os" @@ -202,34 +201,6 @@ func processCacheTo(query *BuildQuery, queryValues url.Values) ([]reference.Name return processCacheReferences(query.CacheTo, "cacheto", queryValues) } -// validateContentType validates the Content-Type header and determines if multipart processing is needed. -func validateContentType(r *http.Request) (bool, error) { - multipart := false - if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 { - contentType, _, err := mime.ParseMediaType(hdr[0]) - if err != nil { - return false, utils.GetBadRequestError("Content-Type", hdr[0], err) - } - - switch contentType { - case "application/tar": - logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) - case "application/x-tar": - break - case "multipart/form-data": - logrus.Infof("Received %s", hdr[0]) - multipart = true - default: - if utils.IsLibpodRequest(r) && !utils.IsLibpodLocalRequest(r) { - return false, utils.GetBadRequestError("Content-Type", hdr[0], - fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0])) - } - logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) - } - } - return multipart, nil -} - // parseBuildQuery parses HTTP query parameters into a BuildQuery struct with defaults. func parseBuildQuery(r *http.Request, conf *config.Config, queryValues url.Values) (*BuildQuery, error) { query := &BuildQuery{ @@ -1039,7 +1010,7 @@ func buildImage(w http.ResponseWriter, r *http.Request, getBuildContextFunc getB // If we have a multipart we use the operations, if not default extraction for main context // Validate content type - multipart, err := validateContentType(r) + multipart, err := utils.ValidateContentType(r) if err != nil { utils.ProcessBuildError(w, err) return diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index 6f87b67a8b..edbb0123fb 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -3,15 +3,23 @@ package libpod import ( + "errors" "fmt" + "io" "net/http" + "os" + "path/filepath" + + "go.podman.io/storage/pkg/archive" "github.com/containers/podman/v5/libpod" "github.com/containers/podman/v5/pkg/api/handlers/utils" api "github.com/containers/podman/v5/pkg/api/types" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/domain/infra/abi" + "github.com/containers/podman/v5/pkg/systemd/quadlet" "github.com/containers/podman/v5/pkg/util" + "github.com/gorilla/schema" "github.com/sirupsen/logrus" ) @@ -56,3 +64,177 @@ func GetQuadletPrint(w http.ResponseWriter, r *http.Request) { return } } + +// extractQuadletFiles extracts quadlet files from tar archive to a temporary directory +func extractQuadletFiles(tempDir string, r io.ReadCloser) ([]string, error) { + quadletDir := filepath.Join(tempDir, "quadlets") + err := os.Mkdir(quadletDir, 0o700) + if err != nil { + return nil, err + } + + err = archive.Untar(r, quadletDir, nil) + if err != nil { + return nil, err + } + + // Collect all files from the extracted directory + var filePaths []string + err = filepath.Walk(quadletDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + filePaths = append(filePaths, path) + } + return nil + }) + + return filePaths, err +} + +// processMultipartQuadlets processes multipart form data and saves files to temporary directory +func processMultipartQuadlets(tempDir string, r *http.Request) ([]string, error) { + quadletDir := filepath.Join(tempDir, "quadlets") + err := os.Mkdir(quadletDir, 0o700) + if err != nil { + return nil, err + } + + reader, err := r.MultipartReader() + if err != nil { + return nil, fmt.Errorf("failed to create multipart reader: %w", err) + } + + var filePaths []string + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read multipart: %w", err) + } + defer part.Close() + + filename := part.FileName() + if filename == "" { + // Skip parts without filenames + continue + } + + // Create file in temp directory + filePath := filepath.Join(quadletDir, filename) + file, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("failed to create file %s: %w", filename, err) + } + defer file.Close() + + _, err = io.Copy(file, part) + if err != nil { + return nil, fmt.Errorf("failed to write file %s: %w", filename, err) + } + + filePaths = append(filePaths, filePath) + } + + return filePaths, nil +} + +func InstallQuadlets(w http.ResponseWriter, r *http.Request) { + // Create temporary directory for processing + contextDirectory, err := os.MkdirTemp("", "libpod_quadlet") + if err != nil { + utils.InternalServerError(w, err) + return + } + defer func() { + if err := os.RemoveAll(contextDirectory); err != nil { + logrus.Warn(fmt.Errorf("failed to remove libpod_quadlet tmp directory %q: %w", contextDirectory, err)) + } + }() + + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + // Parse query parameters + query := struct { + Replace bool `schema:"replace"` + ReloadSystemd bool `schema:"reload-systemd"` + }{ + Replace: false, + ReloadSystemd: true, // Default to true like CLI + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + multipart, err := utils.ValidateContentType(r) + if err != nil { + utils.Error(w, http.StatusBadRequest, err) + return + } + + var filePaths []string + if multipart { + logrus.Debug("Processing multipart form data") + filePaths, err = processMultipartQuadlets(contextDirectory, r) + if err != nil { + utils.InternalServerError(w, err) + return + } + } else { + logrus.Debug("Processing tar archive") + filePaths, err = extractQuadletFiles(contextDirectory, r.Body) + if err != nil { + utils.InternalServerError(w, err) + return + } + } + + if len(filePaths) == 0 { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("no files found in request")) + return + } + + countQuadletFiles := 0 + for _, filePath := range filePaths { + isQuadletFile := quadlet.IsExtSupported(filePath) + if isQuadletFile { + countQuadletFiles++ + } + } + switch { + case countQuadletFiles > 1: + utils.Error(w, http.StatusBadRequest, fmt.Errorf("only a single quadlet file is allowed per request")) + return + case countQuadletFiles == 0: + utils.Error(w, http.StatusBadRequest, fmt.Errorf("no quadlet files found in request")) + return + } + + containerEngine := abi.ContainerEngine{Libpod: runtime} + installOptions := entities.QuadletInstallOptions{ + Replace: query.Replace, + ReloadSystemd: query.ReloadSystemd, + } + + installReport, err := containerEngine.QuadletInstall(r.Context(), filePaths, installOptions) + if err != nil { + utils.InternalServerError(w, err) + return + } + if len(installReport.QuadletErrors) > 0 { + var errs []error + for path, err := range installReport.QuadletErrors { + errs = append(errs, fmt.Errorf("%s: %w", path, err)) + } + utils.Error(w, http.StatusBadRequest, fmt.Errorf("errors occurred installing some Quadlets: %w", errors.Join(errs...))) + return + } + + utils.WriteResponse(w, http.StatusOK, installReport) +} diff --git a/pkg/api/handlers/utils/handler.go b/pkg/api/handlers/utils/handler.go index dec453eddb..810710d19e 100644 --- a/pkg/api/handlers/utils/handler.go +++ b/pkg/api/handlers/utils/handler.go @@ -5,6 +5,7 @@ package utils import ( "fmt" "io" + "mime" "net/http" "net/url" "os" @@ -36,6 +37,34 @@ func IsLibpodLocalRequest(r *http.Request) bool { return apiutil.IsLibpodLocalRequest(r) } +// ValidateContentType validates the Content-Type header and determines if multipart processing is needed. +func ValidateContentType(r *http.Request) (bool, error) { + multipart := false + if hdr, found := r.Header["Content-Type"]; found && len(hdr) > 0 { + contentType, _, err := mime.ParseMediaType(hdr[0]) + if err != nil { + return false, GetBadRequestError("Content-Type", hdr[0], err) + } + + switch contentType { + case "application/tar": + logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) + case "application/x-tar": + break + case "multipart/form-data": + logrus.Infof("Received %s", hdr[0]) + multipart = true + default: + if IsLibpodRequest(r) && !IsLibpodLocalRequest(r) { + return false, GetBadRequestError("Content-Type", hdr[0], + fmt.Errorf("Content-Type: %s is not supported. Should be \"application/x-tar\"", hdr[0])) + } + logrus.Infof("tar file content type is %s, should use \"application/x-tar\" content type", contentType) + } + } + return multipart, nil +} + // SupportedVersion validates that the version provided by client is included in the given condition // https://github.com/blang/semver#ranges provides the details for writing conditions // If a version is not given in URL path, ErrVersionNotGiven is returned diff --git a/pkg/api/server/register_quadlets.go b/pkg/api/server/register_quadlets.go index dd9051a572..798c9edd8c 100644 --- a/pkg/api/server/register_quadlets.go +++ b/pkg/api/server/register_quadlets.go @@ -54,5 +54,60 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/internalError" r.HandleFunc(VersionedPath("/libpod/quadlets/{name}/file"), s.APIHandler(libpod.GetQuadletPrint)).Methods(http.MethodGet) + // swagger:operation POST /libpod/quadlets libpod QuadletInstallLibpod + // --- + // tags: + // - quadlets + // summary: Install quadlet files + // description: | + // Install one or more files for a quadlet application. Each request should contain a single quadlet file + // and optionally more files such as containerfile, kube yaml or configuration files. Supports both tar + // archives and multipart form data uploads. + // consumes: + // - application/x-tar + // - multipart/form-data + // produces: + // - application/json + // parameters: + // - in: query + // name: replace + // type: boolean + // default: false + // description: Replace the installation files even if the files already exists + // - in: query + // name: reload-systemd + // type: boolean + // default: true + // description: Reload systemd after installing quadlets + // - in: body + // name: request + // description: | + // Quadlet files to install. Can be provided as: + // - application/x-tar: A tar archive containing one quadlet file and optionally additional files + // - multipart/form-data: One quadlet file as form data and optionally additional files + // schema: + // type: string + // format: binary + // responses: + // 200: + // description: Quadlet installation report + // schema: + // type: object + // properties: + // InstalledQuadlets: + // type: object + // additionalProperties: + // type: string + // description: Map of source path to installed path for successfully installed quadlets + // QuadletErrors: + // type: object + // additionalProperties: + // type: string + // description: Map of source path to error message for failed installations + // 400: + // $ref: "#/responses/badParamError" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/quadlets"), s.APIHandler(libpod.InstallQuadlets)).Methods(http.MethodPost) return nil } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 2410bf566e..a575de8b8d 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -6,6 +6,24 @@ # NOTE: Once podman-remote quadlet support is added we can enable the podman quadlet tests in # test/system/253-podman-quadlet.bats which should cover it in more detail then. + +function is_rootless() { + [ "$(id -u)" -ne 0 ] +} + +function get_quadlet_install_dir() { + if is_rootless; then + # For rootless: $XDG_CONFIG_HOME/containers/systemd or ~/.config/containers/systemd + local config_home=${XDG_CONFIG_HOME:-$HOME/.config} + echo "$config_home/containers/systemd" + else + # For root: /etc/containers/systemd + echo "/etc/containers/systemd" + fi +} + +quadlet_install_dir=$(get_quadlet_install_dir) + ## Test list endpoint t GET libpod/quadlets/json 200 @@ -18,7 +36,7 @@ quadlet_name=quadlet-test-$(cat /proc/sys/kernel/random/uuid) quadlet_container_name="$quadlet_name.container" quadlet_build_name="$quadlet_name.build" -TMPDIR=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX) +TMPD=$(mktemp -d podman-apiv2-test.quadlet.XXXXXXXX) quadlet_container_file_content=$(cat << EOF [Container] @@ -32,12 +50,12 @@ ImageTag=localhost/$quadlet_name EOF ) -echo "$quadlet_container_file_content" > $TMPDIR/$quadlet_container_name -echo "$quadlet_build_file_content" > $TMPDIR/$quadlet_build_name +echo "$quadlet_container_file_content" > $TMPD/$quadlet_container_name +echo "$quadlet_build_file_content" > $TMPD/$quadlet_build_name # this should ensure the .config/containers/systemd directory is created -podman quadlet install $TMPDIR/$quadlet_container_name -podman quadlet install $TMPDIR/$quadlet_build_name +podman quadlet install $TMPD/$quadlet_container_name +podman quadlet install $TMPD/$quadlet_build_name filter_param=$(printf '{"name":["%s"]}' "$quadlet_name") t GET "libpod/quadlets/json?filters=$filter_param" 200 \ @@ -60,6 +78,228 @@ is "$output" "$quadlet_build_file_content" podman quadlet rm $quadlet_container_name podman quadlet rm $quadlet_build_name -rm -rf $TMPDIR +rm -rf $TMPD + + +TMPD=$(mktemp -d podman-apiv2-test.quadlets.XXXXXXXX) + +# Scenario: try to send nothing +t POST "libpod/quadlets" 400 \ + .cause~.*'Content-Type: application/json is not supported. Should be "application/x-tar"' + +# Scenario: send an empty tar archive will fail with no files found in request +tar -C "$TMPD" -cvf "$TMPD/empty.tar" -T /dev/null &> /dev/null +t POST "libpod/quadlets" "$TMPD/empty.tar" 400 \ + .cause="no files found in request" + +# Scenario: send a plaintext file will fail with no quadlet files found in request +echo "test" > "$TMPD/test.txt" +t POST "libpod/quadlets" --form="test.txt=@$TMPD/test.txt" 400 \ + .cause="no quadlet files found in request" + +# Scenario: send an invalid quadlet type in a tar archive will fail with no quadlet files found in request +echo "test" > "$TMPD/test.txt" +tar -C "$TMPD" -cvf "$TMPD/test.tar" "test.txt" &> /dev/null +t POST "libpod/quadlets" "$TMPD/test.tar" 400 \ + .cause="no quadlet files found in request" + +# Scenario 1: install a single quadlet +quadlet_1=quadlet-test-1-$(cat /proc/sys/kernel/random/uuid).container +quadlet_1_content=$(cat << EOF +[Container] +ContainerName=quadlet-1 +Image=quay.io/podman/hello +EOF +) + +echo "$quadlet_1_content" > "$TMPD/$quadlet_1" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_1.tar" "$quadlet_1" &> /dev/null + +t POST "libpod/quadlets" "$TMPD/$quadlet_1.tar" 200 \ + '.InstalledQuadlets|length=1' \ + '.QuadletErrors|length=0' + +t GET "libpod/quadlets/$quadlet_1/file" 200 +is "$output" "$quadlet_1_content" "quadlet-1 should be installed" + +# Scenario: install a quadlet that already exists, verify it won't be overwritten +# then use replace=true to overwrite it and verify +quadlet_2=$quadlet_1 +quadlet_2_content=$(cat << EOF +[Container] +ContainerName=quadlet-2 +Image=quay.io/podman/hello +EOF +) + +echo "$quadlet_2_content" > "$TMPD/$quadlet_2" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_2.tar" "$quadlet_2" &> /dev/null + +t POST "libpod/quadlets" "$TMPD/$quadlet_2.tar" 400 \ + .cause~.*"a Quadlet with name $quadlet_2 already exists, refusing to overwrite" + +t GET "libpod/quadlets/$quadlet_1/file" 200 +is "$output" "$quadlet_1_content" "quadlet-1 should not be overwritten" + +#replace +t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_2.tar" 200 \ + '.InstalledQuadlets|length=1' \ + '.QuadletErrors|length=0' + +t GET "libpod/quadlets/$quadlet_2/file" 200 +is "$output" "$quadlet_2_content" "quadlet-1 should be overwritten by quadlet-2" + +# Scenario: install multiple quadlets at once in a single tar will fail +quadlet_3=quadlet-test-3-$(cat /proc/sys/kernel/random/uuid).container +quadlet_4=quadlet-test-4-$(cat /proc/sys/kernel/random/uuid).container + +quadlet_3_content=$(cat << EOF +[Container] +ContainerName=quadlet-3 +Image=quay.io/podman/hello +EOF +) + +quadlet_4_content=$(cat << EOF +[Container] +ContainerName=quadlet-4 +Image=quay.io/podman/hello +EOF +) + +echo "$quadlet_3_content" > "$TMPD/$quadlet_3" +echo "$quadlet_4_content" > "$TMPD/$quadlet_4" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_3_4.tar" "$quadlet_3" "$quadlet_4" &> /dev/null + +t POST "libpod/quadlets" "$TMPD/$quadlet_3_4.tar" 400 \ + .cause="only a single quadlet file is allowed per request" + +# Scenario: install tar that contains one quadlet file and a non-quadlet file will succeed +# then update the quadlet file, and the non-quadlet file, and verify the update is successful +quadlet_5=quadlet-test-5-$(cat /proc/sys/kernel/random/uuid).container +containerfile_1=quadlet-test-containerfile-1-$(cat /proc/sys/kernel/random/uuid).Containerfile + +containerfile_1_content=$(cat << EOF +FROM quay.io/podman/hello +CMD ["echo", "hello"] +EOF +) + +quadlet_5_content=$(cat << EOF +[Container] +ContainerName=quadlet-5 +Image=quay.io/podman/hello +EOF +) + +quadlet_5_updated_content=$(cat << EOF +[Container] +ContainerName=quadlet-5-updated +Image=quay.io/podman/hello +EOF +) + +containerfile_1_updated_content=$(cat << EOF +FROM quay.io/podman/hello +CMD ["echo", "Updated"] +EOF +) + +echo "$quadlet_5_content" > "$TMPD/$quadlet_5" +echo "$containerfile_1_content" > "$TMPD/$containerfile_1" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null + +t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ + '.InstalledQuadlets|length=2' \ + '.QuadletErrors|length=0' + +t GET "libpod/quadlets/$quadlet_5/file" 200 +is "$output" "$quadlet_5_content" "quadlet-5 should be installed" +is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" + +echo "$quadlet_5_updated_content" > "$TMPD/$quadlet_5" +echo "$containerfile_1_updated_content" > "$TMPD/$containerfile_1" +tar --format=posix -C "$TMPD" -cvf "$TMPD/$quadlet_5$containerfile_1.tar" "$quadlet_5" "$containerfile_1" &> /dev/null + +# update with no replace and check nothing changed +t POST "libpod/quadlets" "$TMPD/$quadlet_5$containerfile_1.tar" 400 + +t GET "libpod/quadlets/$quadlet_5/file" 200 +is "$output" "$quadlet_5_content" "quadlet-5 should be installed" +is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_content" "containerfile_1 should be installed" + +# replace +t POST "libpod/quadlets?replace=true" "$TMPD/$quadlet_5$containerfile_1.tar" 200 \ + '.InstalledQuadlets|length=2' \ + '.QuadletErrors|length=0' + +t GET "libpod/quadlets/$quadlet_5/file" 200 +is "$output" "$quadlet_5_updated_content" "quadlet-5 should be updated" +is "$(cat "$quadlet_install_dir/$containerfile_1")" "$containerfile_1_updated_content" "containerfile_1 should be installed" + +# Scenario: test a multipart call, then update without replace, and then replace +quadlet_6=quadlet-test-6-$(cat /proc/sys/kernel/random/uuid).container +containerfile_2=quadlet-test-containerfile-2-$(cat /proc/sys/kernel/random/uuid).Containerfile + +quadlet_6_content=$(cat << EOF +[Container] +ContainerName=quadlet-6 +Image=quay.io/podman/hello +EOF +) + +containerfile_2_content=$(cat << EOF +FROM quay.io/podman/hello +CMD ["echo", "hello"] +EOF +) + +quadlet_6_updated_content=$(cat << EOF +[Container] +ContainerName=quadlet-6-updated +Image=quay.io/podman/hello +EOF +) + +containerfile_2_updated_content=$(cat << EOF +FROM quay.io/podman/hello +CMD ["echo", "Updated"] +EOF +) + +echo "$quadlet_6_content" > "$TMPD/$quadlet_6" +echo "$containerfile_2_content" > "$TMPD/$containerfile_2" + +t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200 + +t GET "libpod/quadlets/$quadlet_6/file" 200 +is "$output" "$quadlet_6_content" "quadlet-6 should be installed" +is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should be installed" + +# update with no replace and check nothing changed +echo "$quadlet_6_updated_content" > "$TMPD/$quadlet_6" +echo "$containerfile_2_updated_content" > "$TMPD/$containerfile_2" +t POST "libpod/quadlets" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 400 + +t GET "libpod/quadlets/$quadlet_6/file" 200 +is "$output" "$quadlet_6_content" "quadlet-6 should not be updated" +is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_content" "containerfile_2 should not be updated" + +# replace +t POST "libpod/quadlets?replace=true" --form="quadlet_6=@$TMPD/$quadlet_6" --form="containerfile_2=@$TMPD/$containerfile_2" 200 + +t GET "libpod/quadlets/$quadlet_6/file" 200 +is "$output" "$quadlet_6_updated_content" "quadlet-6 should be updated" +is "$(cat "$quadlet_install_dir/$containerfile_2")" "$containerfile_2_updated_content" "containerfile_2 should be updated" + +# clean up + +podman quadlet rm "$quadlet_1" \ + "$quadlet_5" \ + "$quadlet_6" + +rm -f "$quadlet_install_dir/$containerfile_1" +rm -f "$quadlet_install_dir/$containerfile_2" +rm -rf $TMPD # vim: filetype=sh From d6023e431f7de665f6c70d754bb4619442baf878 Mon Sep 17 00:00:00 2001 From: Nicola Sella Date: Tue, 16 Dec 2025 19:43:49 +0100 Subject: [PATCH 3/4] Add DELETE /libpod/quadlets Fixes: https://issues.redhat.com/browse/RUN-3742 Signed-off-by: Nicola Sella --- libpod/define/errors.go | 3 + pkg/api/handlers/libpod/quadlets.go | 114 ++++++++++++++++++++++++++ pkg/api/handlers/swagger/responses.go | 7 ++ pkg/api/server/register_quadlets.go | 85 +++++++++++++++++++ pkg/domain/infra/abi/quadlet.go | 3 +- test/apiv2/36-quadlets.at | 69 +++++++++++++++- 6 files changed, 279 insertions(+), 2 deletions(-) diff --git a/libpod/define/errors.go b/libpod/define/errors.go index ac502b8bba..09127a9b64 100644 --- a/libpod/define/errors.go +++ b/libpod/define/errors.go @@ -62,6 +62,9 @@ var ( ErrCtrStateInvalid = errors.New("container state improper") // ErrCtrStateRunning indicates a container is running. ErrCtrStateRunning = errors.New("container is running") + // ErrQuadletRunning indicates the quadlet is running and cannot be + // removed without force. + ErrQuadletRunning = errors.New("quadlet is running") // ErrExecSessionStateInvalid indicates that an exec session is in an // improper state for the requested operation ErrExecSessionStateInvalid = errors.New("exec session state improper") diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index edbb0123fb..a6e05a9a6c 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -9,10 +9,12 @@ import ( "net/http" "os" "path/filepath" + "strings" "go.podman.io/storage/pkg/archive" "github.com/containers/podman/v5/libpod" + "github.com/containers/podman/v5/libpod/define" "github.com/containers/podman/v5/pkg/api/handlers/utils" api "github.com/containers/podman/v5/pkg/api/types" "github.com/containers/podman/v5/pkg/domain/entities" @@ -238,3 +240,115 @@ func InstallQuadlets(w http.ResponseWriter, r *http.Request) { utils.WriteResponse(w, http.StatusOK, installReport) } + +// RemoveQuadlet handles DELETE /libpod/quadlets/{name} to remove a quadlet file +func RemoveQuadlet(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + query := struct { + Force bool `schema:"force"` + Ignore bool `schema:"ignore"` + ReloadSystemd bool `schema:"reload-systemd"` + }{ + ReloadSystemd: true, // Default to true like CLI + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + name := utils.GetName(r) + if name == "" { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("quadlet name must be provided")) + return + } + + containerEngine := abi.ContainerEngine{Libpod: runtime} + removeOptions := entities.QuadletRemoveOptions{ + Force: query.Force, + Ignore: query.Ignore, + ReloadSystemd: query.ReloadSystemd, + } + + removeReport, err := containerEngine.QuadletRemove(r.Context(), []string{name}, removeOptions) + if err != nil { + // For systemd connection errors and other internal errors + utils.InternalServerError(w, err) + return + } + + // Check if there are errors in the report for this specific quadlet + if err, ok := removeReport.Errors[name]; ok { + // If ignore=false and quadlet not found, return 404 + if !query.Ignore && strings.Contains(err.Error(), "no such") { + utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s: %w", name, err)) + return + } + // If force=false and quadlet is running, return 400 + if !query.Force && errors.Is(err, define.ErrQuadletRunning) { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("quadlet %s is running and force is not set, refusing to remove: %w", name, err)) + return + } + } + + utils.WriteResponse(w, http.StatusOK, removeReport) +} + +// RemoveQuadlets handles DELETE /libpod/quadlets to remove quadlet files (batch operation) +func RemoveQuadlets(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + decoder := r.Context().Value(api.DecoderKey).(*schema.Decoder) + + query := struct { + All bool `schema:"all"` + Force bool `schema:"force"` + Ignore bool `schema:"ignore"` + ReloadSystemd bool `schema:"reload-systemd"` + Quadlets []string `schema:"quadlets"` + }{ + ReloadSystemd: true, // Default to true like CLI + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("failed to parse parameters for %s: %w", r.URL.String(), err)) + return + } + + // Validate that either all=true OR at least one quadlet name is provided + if !query.All && len(query.Quadlets) == 0 { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("must provide at least 1 quadlet to remove or set all=true")) + return + } + + // Validate that both all and quadlets are not provided together + if query.All && len(query.Quadlets) > 0 { + utils.Error(w, http.StatusBadRequest, fmt.Errorf("when setting all=true, you may not pass any quadlet names")) + return + } + + containerEngine := abi.ContainerEngine{Libpod: runtime} + removeOptions := entities.QuadletRemoveOptions{ + Force: query.Force, + All: query.All, + Ignore: query.Ignore, + ReloadSystemd: query.ReloadSystemd, + } + + removeReport, err := containerEngine.QuadletRemove(r.Context(), query.Quadlets, removeOptions) + if err != nil { + // Check if it's a "must provide at least 1 quadlet" error (shouldn't happen due to validation above, but handle it) + if strings.Contains(err.Error(), "must provide at least 1 quadlet") { + utils.Error(w, http.StatusBadRequest, err) + return + } + // For systemd connection errors and other internal errors + utils.InternalServerError(w, err) + return + } + + // Return 200 with the report containing errors (if any) + // The CLI behavior returns success even with partial errors + utils.WriteResponse(w, http.StatusOK, removeReport) +} diff --git a/pkg/api/handlers/swagger/responses.go b/pkg/api/handlers/swagger/responses.go index 87e7b2d7e0..7013354938 100644 --- a/pkg/api/handlers/swagger/responses.go +++ b/pkg/api/handlers/swagger/responses.go @@ -534,3 +534,10 @@ type quadletFileResponse struct { // in:body Body string } + +// Quadlet remove +// swagger:response +type quadletRemoveResponse struct { + // in:body + Body entities.QuadletRemoveReport +} diff --git a/pkg/api/server/register_quadlets.go b/pkg/api/server/register_quadlets.go index 798c9edd8c..968cb9458f 100644 --- a/pkg/api/server/register_quadlets.go +++ b/pkg/api/server/register_quadlets.go @@ -109,5 +109,90 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/internalError" r.HandleFunc(VersionedPath("/libpod/quadlets"), s.APIHandler(libpod.InstallQuadlets)).Methods(http.MethodPost) + // swagger:operation DELETE /libpod/quadlets libpod QuadletDeleteAllLibpod + // --- + // tags: + // - quadlets + // summary: Remove quadlet files (batch operation) + // description: | + // Remove one or more quadlet files. Supports removing specific quadlets by name or all quadlets + // for the current user. Can force removal of running quadlets and control systemd reload behavior. + // produces: + // - application/json + // parameters: + // - in: query + // name: quadlets + // type: array + // items: + // type: string + // description: Names of quadlets to remove (e.g., "myapp.container"). Required unless all=true + // - in: query + // name: all + // type: boolean + // default: false + // description: Remove all quadlets for the current user + // - in: query + // name: force + // type: boolean + // default: false + // description: Remove running quadlets by stopping them first + // - in: query + // name: ignore + // type: boolean + // default: false + // description: Do not error for quadlets that do not exist + // - in: query + // name: reload-systemd + // type: boolean + // default: true + // description: Reload systemd after removing quadlets + // responses: + // 200: + // $ref: "#/responses/quadletRemoveResponse" + // 400: + // $ref: "#/responses/badParamError" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/quadlets"), s.APIHandler(libpod.RemoveQuadlets)).Methods(http.MethodDelete) + // swagger:operation DELETE /libpod/quadlets/{name} libpod QuadletDeleteLibpod + // --- + // tags: + // - quadlets + // summary: Remove a quadlet file + // description: | + // Remove a quadlet file by name. Can force removal of running quadlets and control systemd reload behavior. + // produces: + // - application/json + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name of the quadlet with extension (e.g., "myapp.container") + // - in: query + // name: force + // type: boolean + // default: false + // description: Remove running quadlet by stopping it first + // - in: query + // name: ignore + // type: boolean + // default: false + // description: Do not error if the quadlet does not exist + // - in: query + // name: reload-systemd + // type: boolean + // default: true + // description: Reload systemd after removing the quadlet + // responses: + // 200: + // $ref: "#/responses/quadletRemoveResponse" + // 400: + // $ref: "#/responses/badParamError" + // 404: + // $ref: "#/responses/quadletNotFound" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/quadlets/{name}"), s.APIHandler(libpod.RemoveQuadlet)).Methods(http.MethodDelete) return nil } diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go index 76bf02f1c7..bf57158b24 100644 --- a/pkg/domain/infra/abi/quadlet.go +++ b/pkg/domain/infra/abi/quadlet.go @@ -17,6 +17,7 @@ import ( "slices" "strings" + "github.com/containers/podman/v5/libpod/define" "github.com/containers/podman/v5/pkg/domain/entities" "github.com/containers/podman/v5/pkg/rootless" "github.com/containers/podman/v5/pkg/systemd" @@ -709,7 +710,7 @@ func (ic *ContainerEngine) QuadletRemove(ctx context.Context, quadlets []string, 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) + report.Errors[quadletName] = fmt.Errorf("quadlet %s is running and force is not set, refusing to remove: %w", quadletName, define.ErrQuadletRunning) runningQuadlets = append(runningQuadlets, quadletName) continue } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index a575de8b8d..7020f32704 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -27,8 +27,11 @@ quadlet_install_dir=$(get_quadlet_install_dir) ## Test list endpoint t GET libpod/quadlets/json 200 +# Test 405 for bad endpoint +t GET libpod/quadlets/nonexistent.container 405 + # Test 404 for non-existent quadlet -t GET libpod/quadlets/nonexistent.container 404 +t GET libpod/quadlets/nonexistent.container/file 404 # Install a quadlet with a unique name quadlet_name=quadlet-test-$(cat /proc/sys/kernel/random/uuid) @@ -47,6 +50,7 @@ EOF quadlet_build_file_content=$(cat << EOF [Build] ImageTag=localhost/$quadlet_name +File=. EOF ) @@ -302,4 +306,67 @@ rm -f "$quadlet_install_dir/$containerfile_1" rm -f "$quadlet_install_dir/$containerfile_2" rm -rf $TMPD +# DELETE endpoint tests +TMPDIR=$(mktemp -d podman-apiv2-test.quadlets.XXXXXXXX) +quadlet_1=quadlet-test-1-$(cat /proc/sys/kernel/random/uuid).container +quadlet_1_content=$(cat << EOF +[Container] +ContainerName=quadlet-1 +Image=quay.io/podman/hello +EOF +) + +echo "$quadlet_1_content" > "$TMPDIR/$quadlet_1" +podman quadlet install $TMPDIR/$quadlet_1 + +t DELETE "libpod/quadlets/$quadlet_1" 200 \ + '.Removed|length=1' \ + '.QuadletErrors|length=0' + +t GET "libpod/quadlets/$quadlet_1/file" 404 + +t DELETE "libpod/quadlets/nonexistent.container" 200 \ + '.Removed|length=0' \ + '.Errors|length=1' \ + '.Errors~.*could not locate quadlet.*' + +t DELETE "libpod/quadlets/nonexistent.container?ignore=true" 200 \ + '.Removed|length=1' \ + '.Errors|length=0' \ + '.Removed[0]=nonexistent.container' + +# Scenario: install a quadlet, then remove it with --force +quadlet_2=quadlet-test-2-$(cat /proc/sys/kernel/random/uuid).container +quadlet_2_content=$(cat << EOF +[Container] +ContainerName=quadlet-2 +Image=$IMAGE +Exec=top +EOF +) +echo "$quadlet_2_content" > "$TMPDIR/$quadlet_2" +podman quadlet install $TMPDIR/$quadlet_2 + +# these ifs will skip the tests when $DBUS_SESSION_BUS_ADDRESS and +# $XDG_RUNTIME_DIR aren't properly defined, therefore running systemctl --user +# is not possible without a hack +if systemctl --user start "${quadlet_2%.*}.service" > /dev/null 2>&1; then + if systemctl --user is-active --quiet ${quadlet_2%.*}.service; then + t DELETE "libpod/quadlets/$quadlet_2" 400 \ + .cause~.*'container'.*'is running and force is not set, refusing to remove'.* + + t DELETE "libpod/quadlets/$quadlet_2?force=true" 200 + fi +fi + +# TODO: enable this test when we have a way to not delete all host's quadlets +# https://issues.redhat.com/browse/RUN-4054 +# t DELETE "libpod/quadlets?all=true" 200 + +# clean up +rm -rf $TMPDIR + +# bunch of asset files might be left behind so we might need to clean them up +find $quadlet_install_dir -type f -regextype posix-extended -regex '.*quadlet-test.*-[0-9a-f]{8}\b-[0-9a-f]{4}\b-[0-9a-f]{4}\b-[0-9a-f]{4}\b-[0-9a-f]{12}.*asset$' -delete; + # vim: filetype=sh From bae88e1e5e4f99acbb31b10c6b9d6d6ea4a20975 Mon Sep 17 00:00:00 2001 From: Nicola Sella Date: Thu, 22 Jan 2026 15:08:11 +0100 Subject: [PATCH 4/4] Add GET /quadlets/{name}/exists Fixes: https://issues.redhat.com/browse/RUN-4068 Signed-off-by: Nicola Sella --- libpod/define/errors.go | 3 +++ pkg/api/handlers/libpod/quadlets.go | 19 +++++++++++++++++++ pkg/api/server/register_quadlets.go | 22 ++++++++++++++++++++++ pkg/domain/entities/engine_container.go | 1 + pkg/domain/infra/abi/quadlet.go | 11 ++++++++++- pkg/domain/infra/tunnel/quadlet.go | 4 ++++ test/apiv2/36-quadlets.at | 24 ++++++++++++++++++++---- 7 files changed, 79 insertions(+), 5 deletions(-) diff --git a/libpod/define/errors.go b/libpod/define/errors.go index 09127a9b64..47b50a6a64 100644 --- a/libpod/define/errors.go +++ b/libpod/define/errors.go @@ -29,6 +29,9 @@ var ( // does not exist. ErrNoSuchExitCode = errors.New("no such exit code") + // ErrNoSuchQuadlet indicates the requested quadlet does not exist + ErrNoSuchQuadlet = errors.New("no such quadlet") + // ErrDepExists indicates that the current object has dependencies and // cannot be removed before them. ErrDepExists = errors.New("dependency exists") diff --git a/pkg/api/handlers/libpod/quadlets.go b/pkg/api/handlers/libpod/quadlets.go index a6e05a9a6c..dbca037d80 100644 --- a/pkg/api/handlers/libpod/quadlets.go +++ b/pkg/api/handlers/libpod/quadlets.go @@ -67,6 +67,25 @@ func GetQuadletPrint(w http.ResponseWriter, r *http.Request) { } } +// QuadletExists checks if a quadlet exists by name +func QuadletExists(w http.ResponseWriter, r *http.Request) { + runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime) + name := utils.GetName(r) + + containerEngine := abi.ContainerEngine{Libpod: runtime} + + report, err := containerEngine.QuadletExists(r.Context(), name) + if err != nil { + utils.InternalServerError(w, err) + return + } + if !report.Value { + utils.Error(w, http.StatusNotFound, fmt.Errorf("no such quadlet: %s", name)) + return + } + utils.WriteResponse(w, http.StatusNoContent, "") +} + // extractQuadletFiles extracts quadlet files from tar archive to a temporary directory func extractQuadletFiles(tempDir string, r io.ReadCloser) ([]string, error) { quadletDir := filepath.Join(tempDir, "quadlets") diff --git a/pkg/api/server/register_quadlets.go b/pkg/api/server/register_quadlets.go index 968cb9458f..1400d1494d 100644 --- a/pkg/api/server/register_quadlets.go +++ b/pkg/api/server/register_quadlets.go @@ -54,6 +54,28 @@ func (s *APIServer) registerQuadletHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/internalError" r.HandleFunc(VersionedPath("/libpod/quadlets/{name}/file"), s.APIHandler(libpod.GetQuadletPrint)).Methods(http.MethodGet) + // swagger:operation GET /libpod/quadlets/{name}/exists libpod QuadletExistsLibpod + // --- + // tags: + // - quadlets + // summary: Check if quadlet exists + // description: Check if a quadlet exists by name + // produces: + // - application/json + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name of the quadlet with extension (e.g., "myapp.container") + // responses: + // 204: + // description: quadlet exists + // 404: + // $ref: "#/responses/quadletNotFound" + // 500: + // $ref: "#/responses/internalError" + r.HandleFunc(VersionedPath("/libpod/quadlets/{name}/exists"), s.APIHandler(libpod.QuadletExists)).Methods(http.MethodGet) // swagger:operation POST /libpod/quadlets libpod QuadletInstallLibpod // --- // tags: diff --git a/pkg/domain/entities/engine_container.go b/pkg/domain/entities/engine_container.go index d81d5c1321..4c31d6967f 100644 --- a/pkg/domain/entities/engine_container.go +++ b/pkg/domain/entities/engine_container.go @@ -93,6 +93,7 @@ type ContainerEngine interface { //nolint:interfacebloat PodStop(ctx context.Context, namesOrIds []string, options PodStopOptions) ([]*PodStopReport, error) PodTop(ctx context.Context, options PodTopOptions) (*StringSliceReport, error) PodUnpause(ctx context.Context, namesOrIds []string, options PodunpauseOptions) ([]*PodUnpauseReport, error) + QuadletExists(ctx context.Context, name string) (*BoolReport, error) QuadletInstall(ctx context.Context, pathsOrURLs []string, options QuadletInstallOptions) (*QuadletInstallReport, error) QuadletList(ctx context.Context, options QuadletListOptions) ([]*ListQuadlet, error) QuadletPrint(ctx context.Context, quadlet string) (string, error) diff --git a/pkg/domain/infra/abi/quadlet.go b/pkg/domain/infra/abi/quadlet.go index bf57158b24..ad848c595a 100644 --- a/pkg/domain/infra/abi/quadlet.go +++ b/pkg/domain/infra/abi/quadlet.go @@ -532,6 +532,15 @@ func (ic *ContainerEngine) QuadletList(ctx context.Context, options entities.Qua return finalReports, nil } +// QuadletExists checks whether a quadlet with the given name exists. +func (ic *ContainerEngine) QuadletExists(_ context.Context, name string) (*entities.BoolReport, error) { + _, err := getQuadletPathByName(name) + if err != nil && !errors.Is(err, define.ErrNoSuchQuadlet) { + return nil, err + } + return &entities.BoolReport{Value: err == nil}, 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 @@ -550,7 +559,7 @@ func getQuadletPathByName(name string) (string, error) { } return testPath, nil } - return "", fmt.Errorf("could not locate quadlet %q in any supported quadlet directory", name) + return "", fmt.Errorf("could not locate quadlet %q in any supported quadlet directory: %w", name, define.ErrNoSuchQuadlet) } func (ic *ContainerEngine) QuadletPrint(_ context.Context, quadlet string) (string, error) { diff --git a/pkg/domain/infra/tunnel/quadlet.go b/pkg/domain/infra/tunnel/quadlet.go index e48d31f79e..c98d0cc57b 100644 --- a/pkg/domain/infra/tunnel/quadlet.go +++ b/pkg/domain/infra/tunnel/quadlet.go @@ -9,6 +9,10 @@ import ( var errNotImplemented = errors.New("not implemented for the remote Podman client") +func (ic *ContainerEngine) QuadletExists(_ context.Context, _ string) (*entities.BoolReport, error) { + return nil, errNotImplemented +} + func (ic *ContainerEngine) QuadletInstall(_ context.Context, _ []string, _ entities.QuadletInstallOptions) (*entities.QuadletInstallReport, error) { return nil, errNotImplemented } diff --git a/test/apiv2/36-quadlets.at b/test/apiv2/36-quadlets.at index 7020f32704..2b616c86f4 100644 --- a/test/apiv2/36-quadlets.at +++ b/test/apiv2/36-quadlets.at @@ -33,6 +33,12 @@ t GET libpod/quadlets/nonexistent.container 405 # Test 404 for non-existent quadlet t GET libpod/quadlets/nonexistent.container/file 404 +# Test 404 for non-existent quadlet exists endpoint +t GET libpod/quadlets/nonexistent.container/exists 404 + +# Test 500 for invalid quadlet extension (not a user-facing "not found" but an input error) +t GET libpod/quadlets/invalid.badext/exists 500 + # Install a quadlet with a unique name quadlet_name=quadlet-test-$(cat /proc/sys/kernel/random/uuid) @@ -80,6 +86,12 @@ is "$output" "$quadlet_container_file_content" t GET "libpod/quadlets/$quadlet_build_name/file" 200 is "$output" "$quadlet_build_file_content" +# Test exists endpoint returns 204 for existing quadlet +t GET "libpod/quadlets/$quadlet_container_name/exists" 204 + +# Test exists endpoint returns 500 for quadlet without extension +t GET "libpod/quadlets/$quadlet_name/exists" 500 + podman quadlet rm $quadlet_container_name podman quadlet rm $quadlet_build_name rm -rf $TMPD @@ -319,16 +331,20 @@ EOF echo "$quadlet_1_content" > "$TMPDIR/$quadlet_1" podman quadlet install $TMPDIR/$quadlet_1 +t GET "libpod/quadlets/$quadlet_1/exists" 204 + t DELETE "libpod/quadlets/$quadlet_1" 200 \ '.Removed|length=1' \ '.QuadletErrors|length=0' t GET "libpod/quadlets/$quadlet_1/file" 404 -t DELETE "libpod/quadlets/nonexistent.container" 200 \ +# Verify exists returns 404 after deletion +t GET "libpod/quadlets/$quadlet_1/exists" 404 + +t DELETE "libpod/quadlets/nonexistent.container" 404 \ '.Removed|length=0' \ - '.Errors|length=1' \ - '.Errors~.*could not locate quadlet.*' + .cause='no such quadlet' t DELETE "libpod/quadlets/nonexistent.container?ignore=true" 200 \ '.Removed|length=1' \ @@ -353,7 +369,7 @@ podman quadlet install $TMPDIR/$quadlet_2 if systemctl --user start "${quadlet_2%.*}.service" > /dev/null 2>&1; then if systemctl --user is-active --quiet ${quadlet_2%.*}.service; then t DELETE "libpod/quadlets/$quadlet_2" 400 \ - .cause~.*'container'.*'is running and force is not set, refusing to remove'.* + .cause~.*'quadlet is running' t DELETE "libpod/quadlets/$quadlet_2?force=true" 200 fi