Merge pull request #26434 from mheon/import_export

Add remote support for `podman volume import` and `podman volume export`
This commit is contained in:
openshift-merge-bot[bot]
2025-06-25 19:48:14 +00:00
committed by GitHub
16 changed files with 290 additions and 166 deletions

View File

@@ -4,14 +4,12 @@ import (
"context"
"errors"
"fmt"
"os"
"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 +19,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 +29,7 @@ Allow content of volume to be exported into external tar.`
)
var (
// Temporary struct to hold cli values.
cliExportOpts = struct {
Output string
}{}
targetPath string
)
func init() {
@@ -46,54 +40,30 @@ 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(&targetPath, outputFlagName, "o", "", "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 == "" {
return errors.New("expects output path, use --output=[path]")
if targetPath == "" && cmd.Flag("output").Changed {
return errors.New("must provide valid path for file to write to")
}
inspectOpts.Type = common.VolumeType
volumeData, errs, err := containerEngine.VolumeInspect(ctx, args, inspectOpts)
exportOpts := entities.VolumeExportOptions{}
if targetPath != "" {
targetFile, err := os.Create(targetPath)
if err != nil {
return err
return fmt.Errorf("unable to create target file path %q: %w", targetPath, err)
}
if len(errs) > 0 {
return errorhandling.JoinErrors(errs)
defer targetFile.Close()
exportOpts.Output = targetFile
} else {
exportOpts.Output = os.Stdout
}
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 containerEngine.VolumeExport(ctx, args[0], exportOpts)
}

View File

@@ -1,7 +1,7 @@
package volumes
import (
"errors"
"context"
"fmt"
"os"
@@ -9,15 +9,12 @@ import (
"github.com/containers/podman/v5/cmd/podman/parse"
"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/spf13/cobra"
)
var (
importDescription = `Imports contents into a podman volume from specified tarball (.tar, .tar.gz, .tgz, .bzip, .tar.xz, .txz).`
importCommand = &cobra.Command{
Annotations: map[string]string{registry.EngineMode: registry.ABIMode},
Use: "import VOLUME [SOURCE]",
Short: "Import a tarball contents into a podman volume",
Long: importDescription,
@@ -37,65 +34,26 @@ func init() {
}
func importVol(cmd *cobra.Command, args []string) error {
var inspectOpts entities.InspectOptions
var tarFile *os.File
containerEngine := registry.ContainerEngine()
ctx := registry.Context()
// create a slice of volumes since inspect expects slice as arg
volumes := []string{args[0]}
tarPath := args[1]
opts := entities.VolumeImportOptions{}
if tarPath != "-" {
err := parse.ValidateFileName(tarPath)
if err != nil {
return err
}
// open tar file
tarFile, err = os.Open(tarPath)
if err != nil {
return err
}
filepath := args[1]
if filepath == "-" {
opts.Input = os.Stdin
} else {
tarFile = os.Stdin
if err := parse.ValidateFileName(filepath); err != nil {
return err
}
inspectOpts.Type = common.VolumeType
inspectOpts.Type = common.VolumeType
volumeData, errs, err := containerEngine.VolumeInspect(ctx, volumes, inspectOpts)
targetFile, err := os.Open(filepath)
if err != nil {
return err
return fmt.Errorf("unable open input file: %w", err)
}
if len(errs) > 0 {
return errorhandling.JoinErrors(errs)
defer targetFile.Close()
opts.Input = targetFile
}
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)
}
}
}
// dont care if volume is mounted or not we are gonna import everything to mountPoint
return utils.UntarToFileSystem(mountPoint, tarFile, nil)
containerEngine := registry.ContainerEngine()
ctx := context.Background()
return containerEngine.VolumeImport(ctx, args[0], opts)
}

View File

@@ -12,8 +12,6 @@ podman\-volume\-export - Export volume to external tar
on the local machine. **podman volume export** writes to STDOUT by default and can be
redirected to a file using the `--output` flag.
Note: Following command is not supported by podman-remote.
**podman volume export [OPTIONS] VOLUME**
## OPTIONS

View File

@@ -14,8 +14,6 @@ The contents of the volume is merged with the content of the tarball with the la
The given volume must already exist and is not created by podman volume import.
Note: Following command is not supported by podman-remote.
#### **--help**
Print usage statement

View File

@@ -3,12 +3,17 @@
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/archive"
"github.com/containers/storage/pkg/directory"
"github.com/sirupsen/logrus"
)
// Volume is a libpod named volume.
@@ -294,3 +299,55 @@ func (v *Volume) Unmount() error {
func (v *Volume) NeedsMount() bool {
return v.needsMount()
}
// Export volume to tar.
// Returns a ReadCloser which points to a tar of all the volume's contents.
func (v *Volume) Export() (io.ReadCloser, error) {
v.lock.Lock()
err := v.mount()
mountPoint := v.mountPoint()
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(mountPoint)
if err != nil {
return nil, fmt.Errorf("creating tar of volume %s contents: %w", v.Name(), err)
}
return volContents, nil
}
// Import a volume from a tar file, provided as an io.Reader.
func (v *Volume) Import(r io.Reader) error {
v.lock.Lock()
err := v.mount()
mountPoint := v.mountPoint()
v.lock.Unlock()
if err != nil {
return 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)
}
}()
if err := archive.Untar(r, mountPoint, nil); err != nil {
return fmt.Errorf("extracting into volume %s: %w", v.Name(), err)
}
return nil
}

View File

@@ -4,12 +4,11 @@ package libpod
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"errors"
"github.com/containers/podman/v5/libpod"
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/pkg/api/handlers/utils"
@@ -222,3 +221,47 @@ 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.VolumeNotFound(w, name, err)
return
}
contents, err := vol.Export()
if err != nil {
utils.Error(w, http.StatusInternalServerError, err)
return
}
utils.WriteResponse(w, http.StatusOK, contents)
}
// ImportVolume imports a volume
func ImportVolume(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.VolumeNotFound(w, name, err)
return
}
if r.Body == nil {
utils.Error(w, http.StatusInternalServerError, errors.New("must provide tar file to import in request body"))
return
}
defer r.Body.Close()
if err := vol.Import(r.Body); err != nil {
utils.Error(w, http.StatusInternalServerError, err)
return
}
utils.WriteResponse(w, http.StatusNoContent, "")
}

View File

@@ -148,6 +148,60 @@ 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)
// swagger:operation POST /libpod/volumes/{name}/import libpod VolumeImportLibpod
// ---
// tags:
// - volumes
// summary: Populate a volume by importing provided tar
// parameters:
// - in: path
// name: name
// type: string
// required: true
// description: the name or ID of the volume
// - in: body
// name: inputStream
// description: |
// An uncompressed tar archive
// schema:
// type: string
// format: binary
// produces:
// - application/json
// responses:
// 204:
// description: Successful import
// 404:
// $ref: "#/responses/volumeNotFound"
// 500:
// $ref: "#/responses/internalError"
r.Handle(VersionedPath("/libpod/volumes/{name}/import"), s.APIHandler(libpod.ImportVolume)).Methods(http.MethodPost)
/*
* Docker compatibility endpoints
*/

View File

@@ -2,6 +2,8 @@ package volumes
import (
"context"
"fmt"
"io"
"net/http"
"strings"
@@ -139,3 +141,39 @@ 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, exportTo io.Writer) error {
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(exportTo, response.Body); err != nil {
return fmt.Errorf("writing volume %s contents to file: %w", nameOrID, err)
}
}
return response.Process(nil)
}
// Import imports the given tar into the given volume
func Import(ctx context.Context, nameOrID string, importFrom io.Reader) error {
conn, err := bindings.GetClient(ctx)
if err != nil {
return err
}
response, err := conn.DoRequest(ctx, importFrom, http.MethodPost, "/volumes/%s/import", nil, nil, nameOrID)
if err != nil {
return err
}
defer response.Body.Close()
return response.Process(nil)
}

View File

@@ -116,4 +116,6 @@ 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
VolumeImport(ctx context.Context, nameOrID string, options VolumeImportOptions) error
}

View File

@@ -1,6 +1,7 @@
package entities
import (
"io"
"net/url"
"github.com/containers/podman/v5/pkg/domain/entities/types"
@@ -37,12 +38,19 @@ 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 {
Output io.Writer
}
// VolumeImportOptions describes the options required to import a volume
type VolumeImportOptions struct {
// Input will be closed upon being fully consumed
Input io.Reader
}

View File

@@ -38,7 +38,7 @@ import (
"github.com/containers/podman/v5/pkg/specgenutil"
"github.com/containers/podman/v5/pkg/systemd/notifyproxy"
"github.com/containers/podman/v5/pkg/util"
"github.com/containers/podman/v5/utils"
"github.com/containers/storage/pkg/archive"
"github.com/containers/storage/pkg/fileutils"
"github.com/coreos/go-systemd/v22/daemon"
"github.com/opencontainers/go-digest"
@@ -1504,7 +1504,7 @@ func (ic *ContainerEngine) importVolume(ctx context.Context, vol *libpod.Volume,
}
// dont care if volume is mounted or not we are gonna import everything to mountPoint
return utils.UntarToFileSystem(mountPoint, tarFile, nil)
return archive.Untar(tarFile, mountPoint, nil)
}
// readConfigMapFromFile returns a kubernetes configMap obtained from --configmap flag

View File

@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"io"
"github.com/containers/podman/v5/libpod"
"github.com/containers/podman/v5/libpod/define"
@@ -246,3 +247,35 @@ 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 {
vol, err := ic.Libpod.LookupVolume(nameOrID)
if err != nil {
return err
}
contents, err := vol.Export()
if err != nil {
return err
}
defer contents.Close()
if _, err := io.Copy(options.Output, contents); err != nil {
return fmt.Errorf("writing volume %s contents: %w", vol.Name(), err)
}
return nil
}
func (ic *ContainerEngine) VolumeImport(ctx context.Context, nameOrID string, options entities.VolumeImportOptions) error {
vol, err := ic.Libpod.LookupVolume(nameOrID)
if err != nil {
return err
}
if err := vol.Import(options.Input); err != nil {
return err
}
return nil
}

View File

@@ -113,3 +113,11 @@ 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.Output)
}
func (ic *ContainerEngine) VolumeImport(ctx context.Context, nameOrID string, options entities.VolumeImportOptions) error {
return volumes.Import(ic.ClientCtx, nameOrID, options.Input)
}

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()
@@ -96,10 +92,6 @@ var _ = Describe("Podman volume create", func() {
})
It("podman create and import 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()
@@ -139,11 +131,11 @@ var _ = Describe("Podman volume create", func() {
session = podmanTest.Podman([]string{"volume", "import", "notfound", "-"})
session.WaitWithDefaultTimeout()
Expect(session).To(ExitWithError(125, "no such volume notfound"))
Expect(session).To(ExitWithError(125, "no volume with name \"notfound\" found"))
session = podmanTest.Podman([]string{"volume", "export", "notfound"})
session.WaitWithDefaultTimeout()
Expect(session).To(ExitWithError(125, "no such volume notfound"))
Expect(session).To(ExitWithError(125, "no volume with name \"notfound\" found"))
})
It("podman create volume with bad volume option", func() {

View File

@@ -242,7 +242,6 @@ EOF
# Podman volume import test
@test "podman volume import test" {
skip_if_remote "volumes import is not applicable on podman-remote"
run_podman volume create --driver local my_vol
run_podman run --rm -v my_vol:/data $IMAGE sh -c "echo hello >> /data/test"
run_podman volume create my_vol2
@@ -260,8 +259,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)"

View File

@@ -50,22 +50,6 @@ func ExecCmdWithStdStreams(stdin io.Reader, stdout, stderr io.Writer, env []stri
return nil
}
// UntarToFileSystem untars an os.file of a tarball to a destination in the filesystem
func UntarToFileSystem(dest string, tarball *os.File, options *archive.TarOptions) error {
logrus.Debugf("untarring %s", tarball.Name())
return archive.Untar(tarball, dest, options)
}
// Creates a new tar file and writes bytes from io.ReadCloser
func CreateTarFromSrc(source string, dest string) error {
file, err := os.Create(dest)
if err != nil {
return fmt.Errorf("could not create tarball file '%s': %w", dest, err)
}
defer file.Close()
return TarChrootToFilesystem(source, file)
}
// TarToFilesystem creates a tarball from source and writes to an os.file
// provided
func TarToFilesystem(source string, tarball *os.File) error {
@@ -88,22 +72,6 @@ func Tar(source string) (io.ReadCloser, error) {
return archive.Tar(source, archive.Uncompressed)
}
// TarChrootToFilesystem creates a tarball from source and writes to an os.file
// provided while chrooted to the source.
func TarChrootToFilesystem(source string, tarball *os.File) error {
tb, err := TarWithChroot(source)
if err != nil {
return err
}
defer tb.Close()
_, err = io.Copy(tarball, tb)
if err != nil {
return err
}
logrus.Debugf("wrote tarball file %s", tarball.Name())
return nil
}
// TarWithChroot creates a tarball from source and returns a readcloser of it
// while chrooted to the source.
func TarWithChroot(source string) (io.ReadCloser, error) {