From cb61a2d858d7874c66bafcf6d17c17bb9d7849e8 Mon Sep 17 00:00:00 2001 From: maybe-sybr <58414429+maybe-sybr@users.noreply.github.com> Date: Wed, 24 Jun 2020 11:16:59 +1000 Subject: APIv2: Add docker compatible volume endpoints This change implements docker compatibile endpoint for interacting with volumes. The code is mostly lifted from the `libpod` API handlers but decodes and constructs data using types defined in the docker API package. Some notable support caveats with the current implementation: * we don't return the nullable `Status` or `UsageData` keys when returning volume information for inspect and create endpoints * we don't support filters when pruning * we return a fixed `0` for the `SpaceReclaimed` key when pruning since we have no insight into how much space was freed from runtime Signed-off-by: Matt Brindley <58414429+maybe-sybr@users.noreply.github.com> --- pkg/api/handlers/compat/volumes.go | 238 +++++++++++++++++++++++++++++++++++++ pkg/api/server/register_volumes.go | 130 +++++++++++++++++++- pkg/domain/entities/volumes.go | 35 ++++++ 3 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 pkg/api/handlers/compat/volumes.go (limited to 'pkg') diff --git a/pkg/api/handlers/compat/volumes.go b/pkg/api/handlers/compat/volumes.go new file mode 100644 index 000000000..3f4e6eac1 --- /dev/null +++ b/pkg/api/handlers/compat/volumes.go @@ -0,0 +1,238 @@ +package compat + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/containers/libpod/libpod" + "github.com/containers/libpod/libpod/define" + "github.com/containers/libpod/pkg/api/handlers/utils" + "github.com/containers/libpod/pkg/domain/filters" + "github.com/containers/libpod/pkg/domain/infra/abi/parse" + docker_api_types "github.com/docker/docker/api/types" + docker_api_types_volume "github.com/docker/docker/api/types/volume" + "github.com/gorilla/schema" + "github.com/pkg/errors" +) + +func ListVolumes(w http.ResponseWriter, r *http.Request) { + var ( + decoder = r.Context().Value("decoder").(*schema.Decoder) + runtime = r.Context().Value("runtime").(*libpod.Runtime) + ) + query := struct { + Filters map[string][]string `schema:"filters"` + }{ + // override any golang type defaults + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest, + errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String())) + return + } + + // Reject any libpod specific filters since `GenerateVolumeFilters()` will + // happily parse them for us. + for filter := range query.Filters { + if filter == "opts" { + utils.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest, + errors.Errorf("unsupported libpod filters passed to docker endpoint")) + return + } + } + volumeFilters, err := filters.GenerateVolumeFilters(query.Filters) + if err != nil { + utils.InternalServerError(w, err) + return + } + + vols, err := runtime.Volumes(volumeFilters...) + if err != nil { + utils.InternalServerError(w, err) + return + } + volumeConfigs := make([]*docker_api_types.Volume, 0, len(vols)) + for _, v := range vols { + config := docker_api_types.Volume{ + Name: v.Name(), + Driver: v.Driver(), + Mountpoint: v.MountPoint(), + CreatedAt: v.CreatedTime().Format(time.RFC3339), + Labels: v.Labels(), + Scope: v.Scope(), + Options: v.Options(), + } + volumeConfigs = append(volumeConfigs, &config) + } + response := docker_api_types_volume.VolumeListOKBody{ + Volumes: volumeConfigs, + Warnings: []string{}, + } + utils.WriteResponse(w, http.StatusOK, response) +} + +func CreateVolume(w http.ResponseWriter, r *http.Request) { + var ( + volumeOptions []libpod.VolumeCreateOption + runtime = r.Context().Value("runtime").(*libpod.Runtime) + decoder = r.Context().Value("decoder").(*schema.Decoder) + ) + /* No query string data*/ + query := struct{}{} + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest, + errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String())) + return + } + // decode params from body + input := docker_api_types_volume.VolumeCreateBody{} + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + utils.Error(w, "Something went wrong.", http.StatusInternalServerError, errors.Wrap(err, "Decode()")) + return + } + + if len(input.Name) > 0 { + volumeOptions = append(volumeOptions, libpod.WithVolumeName(input.Name)) + } + if len(input.Driver) > 0 { + volumeOptions = append(volumeOptions, libpod.WithVolumeDriver(input.Driver)) + } + if len(input.Labels) > 0 { + volumeOptions = append(volumeOptions, libpod.WithVolumeLabels(input.Labels)) + } + if len(input.DriverOpts) > 0 { + parsedOptions, err := parse.VolumeOptions(input.DriverOpts) + if err != nil { + utils.InternalServerError(w, err) + return + } + volumeOptions = append(volumeOptions, parsedOptions...) + } + vol, err := runtime.NewVolume(r.Context(), volumeOptions...) + if err != nil { + utils.InternalServerError(w, err) + return + } + config, err := vol.Config() + if err != nil { + utils.InternalServerError(w, err) + return + } + volResponse := docker_api_types.Volume{ + Name: config.Name, + Driver: config.Driver, + Mountpoint: config.MountPoint, + CreatedAt: config.CreatedTime.Format(time.RFC3339), + Labels: config.Labels, + Options: config.Options, + Scope: "local", + // ^^ We don't have volume scoping so we'll just claim it's "local" + // like we do in the `libpod.Volume.Scope()` method + // + // TODO: We don't include the volume `Status` or `UsageData`, but both + // are nullable in the Docker engine API spec so that's fine for now + } + utils.WriteResponse(w, http.StatusCreated, volResponse) +} + +func InspectVolume(w http.ResponseWriter, r *http.Request) { + var ( + runtime = r.Context().Value("runtime").(*libpod.Runtime) + ) + name := utils.GetName(r) + vol, err := runtime.GetVolume(name) + if err != nil { + utils.VolumeNotFound(w, name, err) + return + } + volResponse := docker_api_types.Volume{ + Name: vol.Name(), + Driver: vol.Driver(), + Mountpoint: vol.MountPoint(), + CreatedAt: vol.CreatedTime().Format(time.RFC3339), + Labels: vol.Labels(), + Options: vol.Options(), + Scope: vol.Scope(), + // TODO: As above, we don't return `Status` or `UsageData` yet + } + utils.WriteResponse(w, http.StatusOK, volResponse) +} + +func RemoveVolume(w http.ResponseWriter, r *http.Request) { + var ( + runtime = r.Context().Value("runtime").(*libpod.Runtime) + decoder = r.Context().Value("decoder").(*schema.Decoder) + ) + query := struct { + Force bool `schema:"force"` + }{ + // override any golang type defaults + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest, + errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String())) + return + } + name := utils.GetName(r) + vol, err := runtime.LookupVolume(name) + if err != nil { + utils.VolumeNotFound(w, name, err) + return + } + if err := runtime.RemoveVolume(r.Context(), vol, query.Force); err != nil { + if errors.Cause(err) == define.ErrVolumeBeingUsed { + utils.Error(w, "volumes being used", http.StatusConflict, err) + return + } + utils.InternalServerError(w, err) + return + } + utils.WriteResponse(w, http.StatusNoContent, "") +} + +func PruneVolumes(w http.ResponseWriter, r *http.Request) { + var ( + runtime = r.Context().Value("runtime").(*libpod.Runtime) + decoder = r.Context().Value("decoder").(*schema.Decoder) + ) + // For some reason the prune filters are query parameters even though this + // is a POST endpoint + query := struct { + Filters map[string][]string `schema:"filters"` + }{ + // override any golang type defaults + } + + if err := decoder.Decode(&query, r.URL.Query()); err != nil { + utils.Error(w, "Something went wrong.", http.StatusBadRequest, errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String())) + return + } + // TODO: We have no ability to pass pruning filters to `PruneVolumes()` so + // we'll explicitly reject the request if we see any + if len(query.Filters) > 0 { + utils.InternalServerError(w, errors.New("filters for pruning volumes is not implemented")) + return + } + + pruned, err := runtime.PruneVolumes(r.Context()) + if err != nil { + utils.InternalServerError(w, err) + return + } + prunedIds := make([]string, 0, len(pruned)) + for k := range pruned { + // XXX: This drops any pruning per-volume error messages on the floor + prunedIds = append(prunedIds, k) + } + pruneResponse := docker_api_types.VolumesPruneReport{ + VolumesDeleted: prunedIds, + // TODO: We don't have any insight into how much space was reclaimed + // from `PruneVolumes()` but it's not nullable + SpaceReclaimed: 0, + } + + utils.WriteResponse(w, http.StatusOK, pruneResponse) +} diff --git a/pkg/api/server/register_volumes.go b/pkg/api/server/register_volumes.go index 1d5abd830..7a64a27ec 100644 --- a/pkg/api/server/register_volumes.go +++ b/pkg/api/server/register_volumes.go @@ -3,12 +3,13 @@ package server import ( "net/http" + "github.com/containers/libpod/pkg/api/handlers/compat" "github.com/containers/libpod/pkg/api/handlers/libpod" "github.com/gorilla/mux" ) func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { - // swagger:operation POST /libpod/volumes/create volumes createVolume + // swagger:operation POST /libpod/volumes/create volumes libpodCreateVolume // --- // summary: Create a volume // parameters: @@ -25,7 +26,7 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // '500': // "$ref": "#/responses/InternalError" r.Handle(VersionedPath("/libpod/volumes/create"), s.APIHandler(libpod.CreateVolume)).Methods(http.MethodPost) - // swagger:operation GET /libpod/volumes/json volumes listVolumes + // swagger:operation GET /libpod/volumes/json volumes libpodListVolumes // --- // summary: List volumes // description: Returns a list of volumes @@ -47,7 +48,7 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // '500': // "$ref": "#/responses/InternalError" r.Handle(VersionedPath("/libpod/volumes/json"), s.APIHandler(libpod.ListVolumes)).Methods(http.MethodGet) - // swagger:operation POST /libpod/volumes/prune volumes pruneVolumes + // swagger:operation POST /libpod/volumes/prune volumes libpodPruneVolumes // --- // summary: Prune volumes // produces: @@ -58,7 +59,7 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // '500': // "$ref": "#/responses/InternalError" r.Handle(VersionedPath("/libpod/volumes/prune"), s.APIHandler(libpod.PruneVolumes)).Methods(http.MethodPost) - // swagger:operation GET /libpod/volumes/{name}/json volumes inspectVolume + // swagger:operation GET /libpod/volumes/{name}/json volumes libpodInspectVolume // --- // summary: Inspect volume // parameters: @@ -77,7 +78,7 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // '500': // "$ref": "#/responses/InternalError" r.Handle(VersionedPath("/libpod/volumes/{name}/json"), s.APIHandler(libpod.InspectVolume)).Methods(http.MethodGet) - // swagger:operation DELETE /libpod/volumes/{name} volumes removeVolume + // swagger:operation DELETE /libpod/volumes/{name} volumes libpodRemoveVolume // --- // summary: Remove volume // parameters: @@ -102,5 +103,124 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // 500: // $ref: "#/responses/InternalError" r.Handle(VersionedPath("/libpod/volumes/{name}"), s.APIHandler(libpod.RemoveVolume)).Methods(http.MethodDelete) + + /* + * Docker compatibility endpoints + */ + + // swagger:operation GET /volumes compat listVolumes + // --- + // summary: List volumes + // description: Returns a list of volume + // produces: + // - application/json + // parameters: + // - in: query + // name: filters + // type: string + // description: | + // JSON encoded value of the filters (a map[string][]string) to process on the volumes list. Available filters: + // - driver= Matches volumes based on their driver. + // - label= or label=: Matches volumes based on the presence of a label alone or a label and a value. + // - name= Matches all of volume name. + // + // Note: + // The boolean `dangling` filter is not yet implemented for this endpoint. + // responses: + // '200': + // "$ref": "#/responses/DockerVolumeList" + // '500': + // "$ref": "#/responses/InternalError" + r.Handle(VersionedPath("/volumes"), s.APIHandler(compat.ListVolumes)).Methods(http.MethodGet) + r.Handle("/volumes", s.APIHandler(compat.ListVolumes)).Methods(http.MethodGet) + + // swagger:operation POST /volumes/create volumes createVolume + // --- + // summary: Create a volume + // parameters: + // - in: body + // name: create + // description: attributes for creating a container + // schema: + // $ref: "#/definitions/DockerVolumeCreate" + // produces: + // - application/json + // responses: + // '201': + // "$ref": "#/responses/DockerVolumeInfoResponse" + // '500': + // "$ref": "#/responses/InternalError" + r.Handle(VersionedPath("/volumes/create"), s.APIHandler(compat.CreateVolume)).Methods(http.MethodPost) + r.Handle("/volumes/create", s.APIHandler(compat.CreateVolume)).Methods(http.MethodPost) + + // swagger:operation GET /volumes/{name} volumes inspectVolume + // --- + // summary: Inspect volume + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name or ID of the volume + // produces: + // - application/json + // responses: + // '200': + // "$ref": "#/responses/DockerVolumeInfoResponse" + // '404': + // "$ref": "#/responses/NoSuchVolume" + // '500': + // "$ref": "#/responses/InternalError" + r.Handle(VersionedPath("/volumes/{name}"), s.APIHandler(compat.InspectVolume)).Methods(http.MethodGet) + r.Handle("/volumes/{name}", s.APIHandler(compat.InspectVolume)).Methods(http.MethodGet) + + // swagger:operation DELETE /volumes/{name} volumes removeVolume + // --- + // summary: Remove volume + // parameters: + // - in: path + // name: name + // type: string + // required: true + // description: the name or ID of the volume + // - in: query + // name: force + // type: boolean + // description: force removal + // produces: + // - application/json + // responses: + // 204: + // description: no error + // 404: + // "$ref": "#/responses/NoSuchVolume" + // 409: + // description: Volume is in use and cannot be removed + // 500: + // "$ref": "#/responses/InternalError" + r.Handle(VersionedPath("/volumes/{name}"), s.APIHandler(compat.RemoveVolume)).Methods(http.MethodDelete) + r.Handle("/volumes/{name}", s.APIHandler(compat.RemoveVolume)).Methods(http.MethodDelete) + + // swagger:operation POST /volumes/prune volumes pruneVolumes + // --- + // summary: Prune volumes + // produces: + // - application/json + // parameters: + // - in: query + // name: filters + // type: string + // description: | + // JSON encoded value of filters (a map[string][]string) to match volumes against before pruning. + // + // Note: No filters are currently supported and any filters specified will cause an error response. + // responses: + // '200': + // "$ref": "#/responses/DockerVolumePruneResponse" + // '500': + // "$ref": "#/responses/InternalError" + r.Handle(VersionedPath("/volumes/prune"), s.APIHandler(compat.PruneVolumes)).Methods(http.MethodPost) + r.Handle("/volumes/prune", s.APIHandler(compat.PruneVolumes)).Methods(http.MethodPost) + return nil } diff --git a/pkg/domain/entities/volumes.go b/pkg/domain/entities/volumes.go index c99b39f2d..2311d1f25 100644 --- a/pkg/domain/entities/volumes.go +++ b/pkg/domain/entities/volumes.go @@ -2,6 +2,9 @@ package entities import ( "time" + + docker_api_types "github.com/docker/docker/api/types" + docker_api_types_volume "github.com/docker/docker/api/types/volume" ) // swagger:model VolumeCreate @@ -90,3 +93,35 @@ type VolumeListOptions struct { type VolumeListReport struct { VolumeConfigResponse } + +/* + * Docker API compatibility types + */ +// swagger:response DockerVolumeList +type SwagDockerVolumeListResponse struct { + // in:body + Body struct { + docker_api_types_volume.VolumeListOKBody + } +} + +// swagger:model DockerVolumeCreate +type DockerVolumeCreate docker_api_types_volume.VolumeCreateBody + +// This response definition is used for both the create and inspect endpoints +// swagger:response DockerVolumeInfoResponse +type SwagDockerVolumeInfoResponse struct { + // in:body + Body struct { + docker_api_types.Volume + } +} + +// Volume prune response +// swagger:response DockerVolumePruneResponse +type SwagDockerVolumePruneResponse struct { + // in:body + Body struct { + docker_api_types.VolumesPruneReport + } +} -- cgit v1.2.3-54-g00ecf From 3aa22cb1be83dbe322c3fabec29b231bdc1be141 Mon Sep 17 00:00:00 2001 From: maybe-sybr <58414429+maybe-sybr@users.noreply.github.com> Date: Wed, 1 Jul 2020 09:49:44 +1000 Subject: APIv2:fix: Handle docker volume force as expected In response to input regarding the semantic difference for the `force` parameter for volume removal between Docker and us, this change ensures that we emulate the Dockr behaviour correctly when this parameter is specified. Signed-off-by: Matt Brindley <58414429+maybe-sybr@users.noreply.github.com> --- pkg/api/handlers/compat/volumes.go | 43 ++++++++++++++++++++++++++++---------- pkg/api/server/register_volumes.go | 5 ++++- 2 files changed, 36 insertions(+), 12 deletions(-) (limited to 'pkg') diff --git a/pkg/api/handlers/compat/volumes.go b/pkg/api/handlers/compat/volumes.go index 3f4e6eac1..1c9366409 100644 --- a/pkg/api/handlers/compat/volumes.go +++ b/pkg/api/handlers/compat/volumes.go @@ -176,21 +176,42 @@ func RemoveVolume(w http.ResponseWriter, r *http.Request) { errors.Wrapf(err, "Failed to parse parameters for %s", r.URL.String())) return } + + /* The implications for `force` differ between Docker and us, so we can't + * simply pass the `force` parameter to `runeimt.RemoveVolume()`. + * Specifically, Docker's behavior seems to be that `force` means "do not + * error on missing volume"; ours means "remove any not-running containers + * using the volume at the same time". + * + * With this in mind, we only consider the `force` query parameter when we + * hunt for specified volume by name, using it to seletively return a 204 + * or blow up depending on `force` being truthy or falsey/unset + * respectively. + */ name := utils.GetName(r) vol, err := runtime.LookupVolume(name) - if err != nil { - utils.VolumeNotFound(w, name, err) - return - } - if err := runtime.RemoveVolume(r.Context(), vol, query.Force); err != nil { - if errors.Cause(err) == define.ErrVolumeBeingUsed { - utils.Error(w, "volumes being used", http.StatusConflict, err) - return + if err == nil { + // As above, we do not pass `force` from the query parameters here + if err := runtime.RemoveVolume(r.Context(), vol, false); err != nil { + if errors.Cause(err) == define.ErrVolumeBeingUsed { + utils.Error(w, "volumes being used", http.StatusConflict, err) + } else { + utils.InternalServerError(w, err) + } + } else { + // Success + utils.WriteResponse(w, http.StatusNoContent, "") + } + } else { + if !query.Force { + utils.VolumeNotFound(w, name, err) + } else { + // Volume does not exist and `force` is truthy - this emulates what + // Docker would do when told to `force` removal of a nonextant + // volume + utils.WriteResponse(w, http.StatusNoContent, "") } - utils.InternalServerError(w, err) - return } - utils.WriteResponse(w, http.StatusNoContent, "") } func PruneVolumes(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/api/server/register_volumes.go b/pkg/api/server/register_volumes.go index 7a64a27ec..0f4f18b0a 100644 --- a/pkg/api/server/register_volumes.go +++ b/pkg/api/server/register_volumes.go @@ -186,7 +186,10 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error { // - in: query // name: force // type: boolean - // description: force removal + // description: | + // Force removal of the volume. This actually only causes errors due + // to the names volume not being found to be suppressed, which is the + // behaviour Docker implements. // produces: // - application/json // responses: -- cgit v1.2.3-54-g00ecf