Refactor podman export to work with the remote client

Previously, our approach was to inspect the volume, grab its
mountpoint, and tar that up, all in the CLI code. There's no
reason why that has to be in the CLI - if we move it into
Libpod, and add a REST endpoint to stream the tar, we can
enable it for the remote client as well.

As a bonus, previously, we could not properly handle volumes that
needed to be mounted. Now, we can mount the volume if necessary,
and as such export works with more types of volumes, including
volume drivers.

Signed-off-by: Matt Heon <mheon@redhat.com>
This commit is contained in:
Matt Heon
2025-06-14 07:22:51 -04:00
parent f69f92cdf7
commit 63bf454d66
11 changed files with 155 additions and 58 deletions

View File

@ -3,15 +3,11 @@ package volumes
import (
"context"
"errors"
"fmt"
"github.com/containers/common/pkg/completion"
"github.com/containers/podman/v5/cmd/podman/common"
"github.com/containers/podman/v5/cmd/podman/registry"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/errorhandling"
"github.com/containers/podman/v5/utils"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
@ -21,7 +17,6 @@ podman volume export
Allow content of volume to be exported into external tar.`
exportCommand = &cobra.Command{
Annotations: map[string]string{registry.EngineMode: registry.ABIMode},
Use: "export [options] VOLUME",
Short: "Export volumes",
Args: cobra.ExactArgs(1),
@ -32,10 +27,7 @@ Allow content of volume to be exported into external tar.`
)
var (
// Temporary struct to hold cli values.
cliExportOpts = struct {
Output string
}{}
cliExportOpts entities.VolumeExportOptions
)
func init() {
@ -46,54 +38,20 @@ func init() {
flags := exportCommand.Flags()
outputFlagName := "output"
flags.StringVarP(&cliExportOpts.Output, outputFlagName, "o", "/dev/stdout", "Write to a specified file (default: stdout, which must be redirected)")
flags.StringVarP(&cliExportOpts.OutputPath, outputFlagName, "o", "/dev/stdout", "Write to a specified file (default: stdout, which must be redirected)")
_ = exportCommand.RegisterFlagCompletionFunc(outputFlagName, completion.AutocompleteDefault)
}
func export(cmd *cobra.Command, args []string) error {
var inspectOpts entities.InspectOptions
containerEngine := registry.ContainerEngine()
ctx := context.Background()
if cliExportOpts.Output == "" {
if cliExportOpts.OutputPath == "" {
return errors.New("expects output path, use --output=[path]")
}
inspectOpts.Type = common.VolumeType
volumeData, errs, err := containerEngine.VolumeInspect(ctx, args, inspectOpts)
if err != nil {
if err := containerEngine.VolumeExport(ctx, args[0], cliExportOpts); err != nil {
return err
}
if len(errs) > 0 {
return errorhandling.JoinErrors(errs)
}
if len(volumeData) < 1 {
return errors.New("no volume data found")
}
mountPoint := volumeData[0].VolumeConfigResponse.Mountpoint
driver := volumeData[0].VolumeConfigResponse.Driver
volumeOptions := volumeData[0].VolumeConfigResponse.Options
volumeMountStatus, err := containerEngine.VolumeMounted(ctx, args[0])
if err != nil {
return err
}
if mountPoint == "" {
return errors.New("volume is not mounted anywhere on host")
}
// Check if volume is using external plugin and export only if volume is mounted
if driver != "" && driver != "local" {
if !volumeMountStatus.Value {
return fmt.Errorf("volume is using a driver %s and volume is not mounted on %s", driver, mountPoint)
}
}
// Check if volume is using `local` driver and has mount options type other than tmpfs
if driver == "local" {
if mountOptionType, ok := volumeOptions["type"]; ok {
if mountOptionType != "tmpfs" && !volumeMountStatus.Value {
return fmt.Errorf("volume is using a driver %s and volume is not mounted on %s", driver, mountPoint)
}
}
}
logrus.Debugf("Exporting volume data from %s to %s", mountPoint, cliExportOpts.Output)
err = utils.CreateTarFromSrc(mountPoint, cliExportOpts.Output)
return err
return nil
}

View File

@ -3,12 +3,16 @@
package libpod
import (
"fmt"
"io"
"time"
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/libpod/lock"
"github.com/containers/podman/v5/libpod/plugin"
"github.com/containers/podman/v5/utils"
"github.com/containers/storage/pkg/directory"
"github.com/sirupsen/logrus"
)
// Volume is a libpod named volume.
@ -294,3 +298,28 @@ func (v *Volume) Unmount() error {
func (v *Volume) NeedsMount() bool {
return v.needsMount()
}
// Returns a ReadCloser which points to a tar of all the volume's contents.
func (v *Volume) ExportVolume() (io.ReadCloser, error) {
v.lock.Lock()
err := v.mount()
v.lock.Unlock()
if err != nil {
return nil, err
}
defer func() {
v.lock.Lock()
defer v.lock.Unlock()
if err := v.unmount(false); err != nil {
logrus.Errorf("Error unmounting volume %s: %v", v.Name(), err)
}
}()
volContents, err := utils.TarWithChroot(v.mountPoint())
if err != nil {
return nil, fmt.Errorf("creating tar of volume %s contents: %w", v.Name(), err)
}
return volContents, nil
}

View File

@ -215,3 +215,22 @@ func ExistsVolume(w http.ResponseWriter, r *http.Request) {
}
utils.WriteResponse(w, http.StatusNoContent, "")
}
// ExportVolume exports a volume
func ExportVolume(w http.ResponseWriter, r *http.Request) {
runtime := r.Context().Value(api.RuntimeKey).(*libpod.Runtime)
name := utils.GetName(r)
vol, err := runtime.GetVolume(name)
if err != nil {
utils.Error(w, http.StatusNotFound, err)
return
}
contents, err := vol.ExportVolume()
if err != nil {
utils.Error(w, http.StatusInternalServerError, err)
return
}
utils.WriteResponse(w, http.StatusOK, contents)
}

View File

@ -148,6 +148,31 @@ func (s *APIServer) registerVolumeHandlers(r *mux.Router) error {
// $ref: "#/responses/internalError"
r.Handle(VersionedPath("/libpod/volumes/{name}"), s.APIHandler(libpod.RemoveVolume)).Methods(http.MethodDelete)
// swagger:operation GET /libpod/volumes/{name}/export libpod VolumeExportLibpod
// ---
// tags:
// - volumes
// summary: Export a volume
// parameters:
// - in: path
// name: name
// type: string
// required: true
// description: the name or ID of the volume
// produces:
// - application/x-tar
// responses:
// 200:
// description: no error
// schema:
// type: string
// format: binary
// 404:
// $ref: "#/responses/volumeNotFound"
// 500:
// $ref: "#/responses/internalError"
r.Handle(VersionedPath("/libpod/volumes/{name}/export"), s.APIHandler(libpod.ExportVolume)).Methods(http.MethodGet)
/*
* Docker compatibility endpoints
*/

View File

@ -2,10 +2,15 @@ package volumes
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/containers/podman/v5/pkg/bindings"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/domain/entities/reports"
entitiesTypes "github.com/containers/podman/v5/pkg/domain/entities/types"
jsoniter "github.com/json-iterator/go"
@ -139,3 +144,33 @@ func Exists(ctx context.Context, nameOrID string, options *ExistsOptions) (bool,
return response.IsSuccess(), nil
}
// Export exports a volume to the given path
func Export(ctx context.Context, nameOrID string, options entities.VolumeExportOptions) error {
if options.OutputPath == "" {
return errors.New("must provide valid path for file to write to")
}
targetFile, err := os.Create(options.OutputPath)
if err != nil {
return fmt.Errorf("unable to create target file path %q: %w", options.OutputPath, err)
}
defer targetFile.Close()
conn, err := bindings.GetClient(ctx)
if err != nil {
return err
}
response, err := conn.DoRequest(ctx, nil, http.MethodGet, "/volumes/%s/export", nil, nil, nameOrID)
if err != nil {
return err
}
defer response.Body.Close()
if response.IsSuccess() || response.IsRedirection() {
if _, err := io.Copy(targetFile, response.Body); err != nil {
return fmt.Errorf("writing volume %s contents to file: %w", nameOrID, err)
}
}
return response.Process(nil)
}

View File

@ -116,4 +116,5 @@ type ContainerEngine interface { //nolint:interfacebloat
VolumeRm(ctx context.Context, namesOrIds []string, opts VolumeRmOptions) ([]*VolumeRmReport, error)
VolumeUnmount(ctx context.Context, namesOrIds []string) ([]*VolumeUnmountReport, error)
VolumeReload(ctx context.Context) (*VolumeReloadReport, error)
VolumeExport(ctx context.Context, nameOrID string, options VolumeExportOptions) error
}

View File

@ -37,12 +37,13 @@ type VolumeListReport = types.VolumeListReport
// VolumeReloadReport describes the response from reload volume plugins
type VolumeReloadReport = types.VolumeReloadReport
/*
* Docker API compatibility types
*/
// VolumeMountReport describes the response from volume mount
type VolumeMountReport = types.VolumeMountReport
// VolumeUnmountReport describes the response from umounting a volume
type VolumeUnmountReport = types.VolumeUnmountReport
// VolumeExportOptions describes the options required to export a volume.
type VolumeExportOptions struct {
OutputPath string
}

View File

@ -6,6 +6,8 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"github.com/containers/podman/v5/libpod"
"github.com/containers/podman/v5/libpod/define"
@ -239,3 +241,32 @@ func (ic *ContainerEngine) VolumeReload(ctx context.Context) (*entities.VolumeRe
report := ic.Libpod.UpdateVolumePlugins(ctx)
return &entities.VolumeReloadReport{VolumeReload: *report}, nil
}
func (ic *ContainerEngine) VolumeExport(ctx context.Context, nameOrID string, options entities.VolumeExportOptions) error {
if options.OutputPath == "" {
return errors.New("must provide valid path for file to write to")
}
targetFile, err := os.Create(options.OutputPath)
if err != nil {
return fmt.Errorf("unable to create target file path %q: %w", options.OutputPath, err)
}
defer targetFile.Close()
vol, err := ic.Libpod.GetVolume(nameOrID)
if err != nil {
return err
}
contents, err := vol.ExportVolume()
if err != nil {
return err
}
defer contents.Close()
if _, err := io.Copy(targetFile, contents); err != nil {
return fmt.Errorf("writing volume %s to file: %w", vol.Name(), err)
}
return nil
}

View File

@ -113,3 +113,7 @@ func (ic *ContainerEngine) VolumeUnmount(ctx context.Context, nameOrIDs []string
func (ic *ContainerEngine) VolumeReload(ctx context.Context) (*entities.VolumeReloadReport, error) {
return nil, errors.New("volume reload is not supported for remote clients")
}
func (ic *ContainerEngine) VolumeExport(ctx context.Context, nameOrID string, options entities.VolumeExportOptions) error {
return volumes.Export(ic.ClientCtx, nameOrID, options)
}

View File

@ -65,10 +65,6 @@ var _ = Describe("Podman volume create", func() {
})
It("podman create and export volume", func() {
if podmanTest.RemoteTest {
Skip("Volume export check does not work with a remote client")
}
volName := "my_vol_" + RandomString(10)
session := podmanTest.Podman([]string{"volume", "create", volName})
session.WaitWithDefaultTimeout()

View File

@ -260,8 +260,6 @@ EOF
# stdout with NULs is easier to test here than in ginkgo
@test "podman volume export to stdout" {
skip_if_remote "N/A on podman-remote"
local volname="myvol_$(random_string 10)"
local mountpoint="/data$(random_string 8)"