From 2e23fcc5a58898d9d7d44a5f642779b22e2b85c0 Mon Sep 17 00:00:00 2001 From: Nicola Sella Date: Tue, 16 Dec 2025 19:43:49 +0100 Subject: [PATCH] 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 613404d289..0d1e88f56c 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/v6/libpod" + "github.com/containers/podman/v6/libpod/define" "github.com/containers/podman/v6/pkg/api/handlers/utils" api "github.com/containers/podman/v6/pkg/api/types" "github.com/containers/podman/v6/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 2d60503bc6..6c55e9a1ed 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 8d006bcae6..6e286b14c3 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 347494f535..84dd192c81 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/v6/libpod/define" "github.com/containers/podman/v6/pkg/domain/entities" "github.com/containers/podman/v6/pkg/rootless" "github.com/containers/podman/v6/pkg/systemd" @@ -896,7 +897,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