podman image scp never enter podman user NS

Podman image scp should never enter the Podman UserNS unless it needs to. This allows for
a sudo exec.Command to transfer images to and from rootful storage. If this command is run using sudo,
the simple sudo podman save/load does not work, machinectl/su is necessary here.

This modification allows for both rootful and rootless transfers, and an overall change of scp to be
more of a wrapper function for different load and save calls as well as the ssh component

Signed-off-by: cdoern <cdoern@redhat.com>
This commit is contained in:
cdoern
2021-11-21 22:48:32 -05:00
parent b6ce7e19ec
commit f6d00ea6ef
15 changed files with 498 additions and 276 deletions

View File

@ -6,18 +6,19 @@ import (
"io/ioutil" "io/ioutil"
urlP "net/url" urlP "net/url"
"os" "os"
"os/exec"
"os/user"
"strconv" "strconv"
"strings" "strings"
"github.com/containers/common/pkg/config" "github.com/containers/common/pkg/config"
"github.com/containers/podman/v3/cmd/podman/common" "github.com/containers/podman/v3/cmd/podman/common"
"github.com/containers/podman/v3/cmd/podman/parse"
"github.com/containers/podman/v3/cmd/podman/registry" "github.com/containers/podman/v3/cmd/podman/registry"
"github.com/containers/podman/v3/cmd/podman/system/connection" "github.com/containers/podman/v3/cmd/podman/system/connection"
"github.com/containers/podman/v3/libpod/define" "github.com/containers/podman/v3/libpod/define"
"github.com/containers/podman/v3/pkg/domain/entities" "github.com/containers/podman/v3/pkg/domain/entities"
"github.com/containers/podman/v3/pkg/rootless" "github.com/containers/podman/v3/pkg/rootless"
"github.com/docker/distribution/reference" "github.com/containers/podman/v3/utils"
scpD "github.com/dtylman/scp" scpD "github.com/dtylman/scp"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
@ -29,7 +30,11 @@ var (
saveScpDescription = `Securely copy an image from one host to another.` saveScpDescription = `Securely copy an image from one host to another.`
imageScpCommand = &cobra.Command{ imageScpCommand = &cobra.Command{
Use: "scp [options] IMAGE [HOST::]", Use: "scp [options] IMAGE [HOST::]",
Annotations: map[string]string{registry.EngineMode: registry.ABIMode}, Annotations: map[string]string{
registry.UnshareNSRequired: "",
registry.ParentNSRequired: "",
registry.EngineMode: registry.ABIMode,
},
Long: saveScpDescription, Long: saveScpDescription,
Short: "securely copy images", Short: "securely copy images",
RunE: scp, RunE: scp,
@ -40,7 +45,10 @@ var (
) )
var ( var (
scpOpts entities.ImageScpOptions parentFlags []string
source entities.ImageScpOptions
dest entities.ImageScpOptions
sshInfo entities.ImageScpConnections
) )
func init() { func init() {
@ -53,7 +61,7 @@ func init() {
func scpFlags(cmd *cobra.Command) { func scpFlags(cmd *cobra.Command) {
flags := cmd.Flags() flags := cmd.Flags()
flags.BoolVarP(&scpOpts.Save.Quiet, "quiet", "q", false, "Suppress the output") flags.BoolVarP(&source.Quiet, "quiet", "q", false, "Suppress the output")
} }
func scp(cmd *cobra.Command, args []string) (finalErr error) { func scp(cmd *cobra.Command, args []string) (finalErr error) {
@ -61,24 +69,31 @@ func scp(cmd *cobra.Command, args []string) (finalErr error) {
// TODO add tag support for images // TODO add tag support for images
err error err error
) )
if scpOpts.Save.Quiet { // set quiet for both load and save for i, val := range os.Args {
scpOpts.Load.Quiet = true if val == "image" {
break
} }
f, err := ioutil.TempFile("", "podman") // open temp file for load/save output if i == 0 {
continue
}
if strings.Contains(val, "CIRRUS") { // need to skip CIRRUS flags for testing suite purposes
continue
}
parentFlags = append(parentFlags, val)
}
podman, err := os.Executable()
if err != nil { if err != nil {
return err return err
} }
defer os.Remove(f.Name()) f, err := ioutil.TempFile("", "podman") // open temp file for load/save output
if err != nil {
scpOpts.Save.Output = f.Name()
scpOpts.Load.Input = scpOpts.Save.Output
if err := parse.ValidateFileName(saveOpts.Output); err != nil {
return err return err
} }
confR, err := config.NewConfig("") // create a hand made config for the remote engine since we might use remote and native at once confR, err := config.NewConfig("") // create a hand made config for the remote engine since we might use remote and native at once
if err != nil { if err != nil {
return errors.Wrapf(err, "could not make config") return errors.Wrapf(err, "could not make config")
} }
abiEng, err := registry.NewImageEngine(cmd, args) // abi native engine abiEng, err := registry.NewImageEngine(cmd, args) // abi native engine
if err != nil { if err != nil {
return err return err
@ -88,77 +103,115 @@ func scp(cmd *cobra.Command, args []string) (finalErr error) {
if err != nil { if err != nil {
return err return err
} }
serv, err := parseArgs(args, cfg) // parses connection data and "which way" we are loading and saving locations := []*entities.ImageScpOptions{}
cliConnections := []string{}
flipConnections := false
for _, arg := range args {
loc, connect, err := parseImageSCPArg(arg)
if err != nil { if err != nil {
return err return err
} }
locations = append(locations, loc)
cliConnections = append(cliConnections, connect...)
}
source = *locations[0]
switch {
case len(locations) > 1:
if flipConnections, err = validateSCPArgs(locations); err != nil {
return err
}
if flipConnections { // the order of cliConnections matters, we need to flip both arrays since the args are parsed separately sometimes.
connect := cliConnections[0]
cliConnections[0] = cliConnections[1]
cliConnections[1] = connect
loc := locations[0]
locations[0] = locations[1]
locations[1] = loc
}
dest = *locations[1]
case len(locations) == 1:
switch {
case len(locations[0].Image) == 0:
return errors.Wrapf(define.ErrInvalidArg, "no source image specified")
case len(locations[0].Image) > 0 && !locations[0].Remote && len(locations[0].User) == 0: // if we have podman image scp $IMAGE
return errors.Wrapf(define.ErrInvalidArg, "must specify a destination")
}
}
source.File = f.Name() // after parsing the arguments, set the file for the save/load
dest.File = source.File
if err = os.Remove(source.File); err != nil { // remove the file and simply use its name so podman creates the file upon save. avoids umask errors
return err
}
var serv map[string]config.Destination
serv, err = GetServiceInformation(cliConnections, cfg)
if err != nil {
return err
}
// TODO: Add podman remote support // TODO: Add podman remote support
confR.Engine = config.EngineConfig{Remote: true, CgroupManager: "cgroupfs", ServiceDestinations: serv} // pass the service dest (either remote or something else) to engine confR.Engine = config.EngineConfig{Remote: true, CgroupManager: "cgroupfs", ServiceDestinations: serv} // pass the service dest (either remote or something else) to engine
saveCmd, loadCmd := createCommands(podman)
switch { switch {
case scpOpts.FromRemote: // if we want to load FROM the remote case source.Remote: // if we want to load FROM the remote, dest can either be local or remote in this case
err = saveToRemote(scpOpts.SourceImageName, scpOpts.Save.Output, "", scpOpts.URI[0], scpOpts.Iden[0]) err = saveToRemote(source.Image, source.File, "", sshInfo.URI[0], sshInfo.Identities[0])
if err != nil { if err != nil {
return err return err
} }
if scpOpts.ToRemote { // we want to load remote -> remote if dest.Remote { // we want to load remote -> remote, both source and dest are remote
rep, err := loadToRemote(scpOpts.Save.Output, "", scpOpts.URI[1], scpOpts.Iden[1]) rep, err := loadToRemote(dest.File, "", sshInfo.URI[1], sshInfo.Identities[1])
if err != nil { if err != nil {
return err return err
} }
fmt.Println(rep) fmt.Println(rep)
break break
} }
report, err := abiEng.Load(context.Background(), scpOpts.Load) err = execPodman(podman, loadCmd)
if err != nil { if err != nil {
return err return err
} }
fmt.Println("Loaded image(s): " + strings.Join(report.Names, ",")) case dest.Remote: // remote host load, implies source is local
case scpOpts.ToRemote: // remote host load err = execPodman(podman, saveCmd)
scpOpts.Save.Format = "oci-archive" if err != nil {
abiErr := abiEng.Save(context.Background(), scpOpts.SourceImageName, []string{}, scpOpts.Save) // save the image locally before loading it on remote, local, or ssh return err
if abiErr != nil {
errors.Wrapf(abiErr, "could not save image as specified")
} }
rep, err := loadToRemote(scpOpts.Save.Output, "", scpOpts.URI[0], scpOpts.Iden[0]) rep, err := loadToRemote(source.File, "", sshInfo.URI[0], sshInfo.Identities[0])
if err != nil { if err != nil {
return err return err
} }
fmt.Println(rep) fmt.Println(rep)
if err = os.Remove(source.File); err != nil {
return err
}
// TODO: Add podman remote support // TODO: Add podman remote support
default: // else native load default: // else native load, both source and dest are local and transferring between users
scpOpts.Save.Format = "oci-archive" if source.User == "" { // source user has to be set, destination does not
_, err := os.Open(scpOpts.Save.Output) source.User = os.Getenv("USER")
if source.User == "" {
u, err := user.Current()
if err != nil {
return errors.Wrapf(err, "could not obtain user, make sure the environmental variable $USER is set")
}
source.User = u.Username
}
}
err := abiEng.Transfer(context.Background(), source, dest, parentFlags)
if err != nil { if err != nil {
return err return err
} }
if scpOpts.Tag != "" {
return errors.Wrapf(define.ErrInvalidArg, "Renaming of an image is currently not supported")
}
scpOpts.Save.Format = "oci-archive"
abiErr := abiEng.Save(context.Background(), scpOpts.SourceImageName, []string{}, scpOpts.Save) // save the image locally before loading it on remote, local, or ssh
if abiErr != nil {
return errors.Wrapf(abiErr, "could not save image as specified")
}
if !rootless.IsRootless() && scpOpts.Rootless {
if scpOpts.User == "" {
scpOpts.User = os.Getenv("SUDO_USER")
if scpOpts.User == "" {
return errors.New("could not obtain root user, make sure the environmental variable SUDO_USER is set, and that this command is being run as root")
}
}
err := abiEng.Transfer(context.Background(), scpOpts)
if err != nil {
return err
}
} else {
rep, err := abiEng.Load(context.Background(), scpOpts.Load)
if err != nil {
return err
}
fmt.Println("Loaded image(s): " + strings.Join(rep.Names, ","))
}
} }
src, err := json.MarshalIndent(source, "", " ")
if err != nil {
return err
}
dst, err := json.MarshalIndent(dest, "", " ")
if err != nil {
return err
}
fmt.Printf("SOURCE: %s\nDEST: %s\n", string(src), string(dst))
return nil return nil
} }
@ -249,119 +302,28 @@ func createConnection(url *urlP.URL, iden string) (*ssh.Client, string, error) {
return dialAdd, file, nil return dialAdd, file, nil
} }
// validateImageName makes sure that the image given is valid and no injections are occurring // GetSerivceInformation takes the parsed list of hosts to connect to and validates the information
// we simply use this for error checking, bot setting the image func GetServiceInformation(cliConnections []string, cfg *config.Config) (map[string]config.Destination, error) {
func validateImageName(input string) error { var serv map[string]config.Destination
// ParseNormalizedNamed transforms a shortname image into its
// full name reference so busybox => docker.io/library/busybox
// we want to keep our shortnames, so only return an error if
// we cannot parse what th euser has given us
_, err := reference.ParseNormalizedNamed(input)
return err
}
// remoteArgLength is a helper function to simplify the extracting of host argument data
// returns an int which contains the length of a specified index in a host::image string
func remoteArgLength(input string, side int) int {
return len((strings.Split(input, "::"))[side])
}
// parseArgs returns the valid connection data based off of the information provided by the user
// args is an array of the command arguments and cfg is tooling configuration used to get service destinations
// returned is serv and an error if applicable. serv is a map of service destinations with the connection name as the index
// this connection name is intended to be used as EngineConfig.ServiceDestinations
// this function modifies the global scpOpt entities: FromRemote, ToRemote, Connections, and SourceImageName
func parseArgs(args []string, cfg *config.Config) (map[string]config.Destination, error) {
serv := map[string]config.Destination{}
cliConnections := []string{}
switch len(args) {
case 1:
if strings.Contains(args[0], "localhost") {
if strings.Split(args[0], "@")[0] != "root" {
return nil, errors.Wrapf(define.ErrInvalidArg, "cannot transfer images from any user besides root using sudo")
}
scpOpts.Rootless = true
scpOpts.SourceImageName = strings.Split(args[0], "::")[1]
} else if strings.Contains(args[0], "::") {
scpOpts.FromRemote = true
cliConnections = append(cliConnections, args[0])
} else {
err := validateImageName(args[0])
if err != nil {
return nil, err
}
scpOpts.SourceImageName = args[0]
}
case 2:
if strings.Contains(args[0], "localhost") || strings.Contains(args[1], "localhost") { // only supporting root to local using sudo at the moment
if strings.Split(args[0], "@")[0] != "root" {
return nil, errors.Wrapf(define.ErrInvalidArg, "currently, transferring images to a user account is not supported")
}
if len(strings.Split(args[0], "::")) > 1 {
scpOpts.Rootless = true
scpOpts.User = strings.Split(args[1], "@")[0]
scpOpts.SourceImageName = strings.Split(args[0], "::")[1]
} else {
return nil, errors.Wrapf(define.ErrInvalidArg, "currently, you cannot rename images during the transfer or transfer them to a user account")
}
} else if strings.Contains(args[0], "::") {
if !(strings.Contains(args[1], "::")) && remoteArgLength(args[0], 1) == 0 { // if an image is specified, this mean we are loading to our client
cliConnections = append(cliConnections, args[0])
scpOpts.ToRemote = true
scpOpts.SourceImageName = args[1]
} else if strings.Contains(args[1], "::") { // both remote clients
scpOpts.FromRemote = true
scpOpts.ToRemote = true
if remoteArgLength(args[0], 1) == 0 { // is save->load w/ one image name
cliConnections = append(cliConnections, args[0])
cliConnections = append(cliConnections, args[1])
} else if remoteArgLength(args[0], 1) > 0 && remoteArgLength(args[1], 1) > 0 {
//in the future, this function could, instead of rejecting renames, also set a DestImageName field
return nil, errors.Wrapf(define.ErrInvalidArg, "cannot specify an image rename")
} else { // else its a load save (order of args)
cliConnections = append(cliConnections, args[1])
cliConnections = append(cliConnections, args[0])
}
} else {
//in the future, this function could, instead of rejecting renames, also set a DestImageName field
return nil, errors.Wrapf(define.ErrInvalidArg, "cannot specify an image rename")
}
} else if strings.Contains(args[1], "::") { // if we are given image host::
if remoteArgLength(args[1], 1) > 0 {
//in the future, this function could, instead of rejecting renames, also set a DestImageName field
return nil, errors.Wrapf(define.ErrInvalidArg, "cannot specify an image rename")
}
err := validateImageName(args[0])
if err != nil {
return nil, err
}
scpOpts.SourceImageName = args[0]
scpOpts.ToRemote = true
cliConnections = append(cliConnections, args[1])
} else {
//in the future, this function could, instead of rejecting renames, also set a DestImageName field
return nil, errors.Wrapf(define.ErrInvalidArg, "cannot specify an image rename")
}
}
var url string var url string
var iden string var iden string
for i, val := range cliConnections { for i, val := range cliConnections {
splitEnv := strings.SplitN(val, "::", 2) splitEnv := strings.SplitN(val, "::", 2)
scpOpts.Connections = append(scpOpts.Connections, splitEnv[0]) sshInfo.Connections = append(sshInfo.Connections, splitEnv[0])
if len(splitEnv[1]) != 0 { if len(splitEnv[1]) != 0 {
err := validateImageName(splitEnv[1]) err := validateImageName(splitEnv[1])
if err != nil { if err != nil {
return nil, err return nil, err
} }
scpOpts.SourceImageName = splitEnv[1] source.Image = splitEnv[1]
//TODO: actually use the new name given by the user //TODO: actually use the new name given by the user
} }
conn, found := cfg.Engine.ServiceDestinations[scpOpts.Connections[i]] conn, found := cfg.Engine.ServiceDestinations[sshInfo.Connections[i]]
if found { if found {
url = conn.URI url = conn.URI
iden = conn.Identity iden = conn.Identity
} else { // no match, warn user and do a manual connection. } else { // no match, warn user and do a manual connection.
url = "ssh://" + scpOpts.Connections[i] url = "ssh://" + sshInfo.Connections[i]
iden = "" iden = ""
logrus.Warnf("Unknown connection name given. Please use system connection add to specify the default remote socket location") logrus.Warnf("Unknown connection name given. Please use system connection add to specify the default remote socket location")
} }
@ -374,8 +336,45 @@ func parseArgs(args []string, cfg *config.Config) (map[string]config.Destination
return nil, err return nil, err
} }
} }
scpOpts.URI = append(scpOpts.URI, urlT) sshInfo.URI = append(sshInfo.URI, urlT)
scpOpts.Iden = append(scpOpts.Iden, iden) sshInfo.Identities = append(sshInfo.Identities, iden)
} }
return serv, nil return serv, nil
} }
// execPodman executes the podman save/load command given the podman binary
func execPodman(podman string, command []string) error {
if rootless.IsRootless() {
cmd := exec.Command(podman)
utils.CreateSCPCommand(cmd, command[1:])
logrus.Debug("Executing podman command")
return cmd.Run()
}
machinectl, err := exec.LookPath("machinectl")
if err != nil {
cmd := exec.Command("su", "-l", "root", "--command")
cmd = utils.CreateSCPCommand(cmd, []string{strings.Join(command, " ")})
return cmd.Run()
}
cmd := exec.Command(machinectl, "shell", "-q", "root@.host")
cmd = utils.CreateSCPCommand(cmd, command)
logrus.Debug("Executing load command machinectl")
return cmd.Run()
}
// createCommands forms the podman save and load commands used by SCP
func createCommands(podman string) ([]string, []string) {
var parentString string
quiet := ""
if source.Quiet {
quiet = "-q "
}
if len(parentFlags) > 0 {
parentString = strings.Join(parentFlags, " ") + " " // if there are parent args, an extra space needs to be added
} else {
parentString = strings.Join(parentFlags, " ")
}
loadCmd := strings.Split(fmt.Sprintf("%s %sload %s--input %s", podman, parentString, quiet, dest.File), " ")
saveCmd := strings.Split(fmt.Sprintf("%s %vsave %s--output %s %s", podman, parentString, quiet, source.File, source.Image), " ")
return saveCmd, loadCmd
}

View File

@ -0,0 +1,46 @@
package images
import (
"testing"
"github.com/containers/podman/v3/pkg/domain/entities"
"github.com/stretchr/testify/assert"
)
func TestParseSCPArgs(t *testing.T) {
args := []string{"alpine", "root@localhost::"}
var source *entities.ImageScpOptions
var dest *entities.ImageScpOptions
var err error
source, _, err = parseImageSCPArg(args[0])
assert.Nil(t, err)
assert.Equal(t, source.Image, "alpine")
dest, _, err = parseImageSCPArg(args[1])
assert.Nil(t, err)
assert.Equal(t, dest.Image, "")
assert.Equal(t, dest.User, "root")
args = []string{"root@localhost::alpine"}
source, _, err = parseImageSCPArg(args[0])
assert.Nil(t, err)
assert.Equal(t, source.User, "root")
assert.Equal(t, source.Image, "alpine")
args = []string{"charliedoern@192.168.68.126::alpine", "foobar@192.168.68.126::"}
source, _, err = parseImageSCPArg(args[0])
assert.Nil(t, err)
assert.True(t, source.Remote)
assert.Equal(t, source.Image, "alpine")
dest, _, err = parseImageSCPArg(args[1])
assert.Nil(t, err)
assert.True(t, dest.Remote)
assert.Equal(t, dest.Image, "")
args = []string{"charliedoern@192.168.68.126::alpine"}
source, _, err = parseImageSCPArg(args[0])
assert.Nil(t, err)
assert.True(t, source.Remote)
assert.Equal(t, source.Image, "alpine")
}

View File

@ -0,0 +1,87 @@
package images
import (
"strings"
"github.com/containers/image/v5/docker/reference"
"github.com/containers/podman/v3/libpod/define"
"github.com/containers/podman/v3/pkg/domain/entities"
"github.com/pkg/errors"
)
// parseImageSCPArg returns the valid connection, and source/destination data based off of the information provided by the user
// arg is a string containing one of the cli arguments returned is a filled out source/destination options structs as well as a connections array and an error if applicable
func parseImageSCPArg(arg string) (*entities.ImageScpOptions, []string, error) {
location := entities.ImageScpOptions{}
var err error
cliConnections := []string{}
switch {
case strings.Contains(arg, "@localhost"): // image transfer between users
location.User = strings.Split(arg, "@")[0]
location, err = validateImagePortion(location, arg)
if err != nil {
return nil, nil, err
}
case strings.Contains(arg, "::"):
location, err = validateImagePortion(location, arg)
if err != nil {
return nil, nil, err
}
location.Remote = true
cliConnections = append(cliConnections, arg)
default:
location.Image = arg
}
return &location, cliConnections, nil
}
// validateImagePortion is a helper function to validate the image name in an SCP argument
func validateImagePortion(location entities.ImageScpOptions, arg string) (entities.ImageScpOptions, error) {
if remoteArgLength(arg, 1) > 0 {
err := validateImageName(strings.Split(arg, "::")[1])
if err != nil {
return location, err
}
location.Image = strings.Split(arg, "::")[1] // this will get checked/set again once we validate connections
}
return location, nil
}
// validateSCPArgs takes the array of source and destination options and checks for common errors
func validateSCPArgs(locations []*entities.ImageScpOptions) (bool, error) {
if len(locations) > 2 {
return false, errors.Wrapf(define.ErrInvalidArg, "cannot specify more than two arguments")
}
switch {
case len(locations[0].Image) > 0 && len(locations[1].Image) > 0:
return false, errors.Wrapf(define.ErrInvalidArg, "cannot specify an image rename")
case len(locations[0].Image) == 0 && len(locations[1].Image) == 0:
return false, errors.Wrapf(define.ErrInvalidArg, "a source image must be specified")
case len(locations[0].Image) == 0 && len(locations[1].Image) != 0:
if locations[0].Remote && locations[1].Remote {
return true, nil // we need to flip the cliConnections array so the save/load connections are in the right place
}
}
return false, nil
}
// validateImageName makes sure that the image given is valid and no injections are occurring
// we simply use this for error checking, bot setting the image
func validateImageName(input string) error {
// ParseNormalizedNamed transforms a shortname image into its
// full name reference so busybox => docker.io/library/busybox
// we want to keep our shortnames, so only return an error if
// we cannot parse what the user has given us
_, err := reference.ParseNormalizedNamed(input)
return err
}
// remoteArgLength is a helper function to simplify the extracting of host argument data
// returns an int which contains the length of a specified index in a host::image string
func remoteArgLength(input string, side int) int {
if strings.Contains(input, "::") {
return len((strings.Split(input, "::"))[side])
}
return -1
}

View File

@ -52,14 +52,14 @@ func parseCommands() *cobra.Command {
// Command cannot be run rootless // Command cannot be run rootless
_, found := c.Command.Annotations[registry.UnshareNSRequired] _, found := c.Command.Annotations[registry.UnshareNSRequired]
if found { if found {
if rootless.IsRootless() && os.Getuid() != 0 { if rootless.IsRootless() && os.Getuid() != 0 && c.Command.Name() != "scp" {
c.Command.RunE = func(cmd *cobra.Command, args []string) error { c.Command.RunE = func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot run command %q in rootless mode, must execute `podman unshare` first", cmd.CommandPath()) return fmt.Errorf("cannot run command %q in rootless mode, must execute `podman unshare` first", cmd.CommandPath())
} }
} }
} else { } else {
_, found = c.Command.Annotations[registry.ParentNSRequired] _, found = c.Command.Annotations[registry.ParentNSRequired]
if rootless.IsRootless() && found { if rootless.IsRootless() && found && c.Command.Name() != "scp" {
c.Command.RunE = func(cmd *cobra.Command, args []string) error { c.Command.RunE = func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("cannot run command %q in rootless mode", cmd.CommandPath()) return fmt.Errorf("cannot run command %q in rootless mode", cmd.CommandPath())
} }

View File

@ -165,6 +165,7 @@ setup_rootless() {
groupadd -g $rootless_gid $ROOTLESS_USER groupadd -g $rootless_gid $ROOTLESS_USER
useradd -g $rootless_gid -u $rootless_uid --no-user-group --create-home $ROOTLESS_USER useradd -g $rootless_gid -u $rootless_uid --no-user-group --create-home $ROOTLESS_USER
chown -R $ROOTLESS_USER:$ROOTLESS_USER "$GOPATH" "$GOSRC" chown -R $ROOTLESS_USER:$ROOTLESS_USER "$GOPATH" "$GOSRC"
echo "$ROOTLESS_USER ALL=(root) NOPASSWD: ALL" > /etc/sudoers.d/ci-rootless
mkdir -p "$HOME/.ssh" "/home/$ROOTLESS_USER/.ssh" mkdir -p "$HOME/.ssh" "/home/$ROOTLESS_USER/.ssh"

View File

@ -27,7 +27,7 @@ type ImageEngine interface {
ShowTrust(ctx context.Context, args []string, options ShowTrustOptions) (*ShowTrustReport, error) ShowTrust(ctx context.Context, args []string, options ShowTrustOptions) (*ShowTrustReport, error)
Shutdown(ctx context.Context) Shutdown(ctx context.Context)
Tag(ctx context.Context, nameOrID string, tags []string, options ImageTagOptions) error Tag(ctx context.Context, nameOrID string, tags []string, options ImageTagOptions) error
Transfer(ctx context.Context, scpOpts ImageScpOptions) error Transfer(ctx context.Context, source ImageScpOptions, dest ImageScpOptions, parentFlags []string) error
Tree(ctx context.Context, nameOrID string, options ImageTreeOptions) (*ImageTreeReport, error) Tree(ctx context.Context, nameOrID string, options ImageTreeOptions) (*ImageTreeReport, error)
Unmount(ctx context.Context, images []string, options ImageUnmountOptions) ([]*ImageUnmountReport, error) Unmount(ctx context.Context, images []string, options ImageUnmountOptions) ([]*ImageUnmountReport, error)
Untag(ctx context.Context, nameOrID string, tags []string, options ImageUntagOptions) error Untag(ctx context.Context, nameOrID string, tags []string, options ImageUntagOptions) error

View File

@ -311,30 +311,28 @@ type ImageSaveOptions struct {
Quiet bool Quiet bool
} }
// ImageScpOptions provide options for securely copying images to podman remote // ImageScpOptions provide options for securely copying images to and from a remote host
type ImageScpOptions struct { type ImageScpOptions struct {
// SoureImageName is the image the user is providing to load on a remote machine // Remote determines if this entity is operating on a remote machine
SourceImageName string Remote bool `json:"remote,omitempty"`
// Tag allows for a new image to be created under the given name // File is the input/output file for the save and load Operation
Tag string File string `json:"file,omitempty"`
// ToRemote specifies that we are loading to the remote host // Quiet Determines if the save and load operation will be done quietly
ToRemote bool Quiet bool `json:"quiet,omitempty"`
// FromRemote specifies that we are loading from the remote host // Image is the image the user is providing to save and load
FromRemote bool Image string `json:"image,omitempty"`
// User is used in conjunction with Transfer to determine if a valid user was given to save from/load into
User string `json:"user,omitempty"`
}
// ImageScpConnections provides the ssh related information used in remote image transfer
type ImageScpConnections struct {
// Connections holds the raw string values for connections (ssh or unix) // Connections holds the raw string values for connections (ssh or unix)
Connections []string Connections []string
// URI contains the ssh connection URLs to be used by the client // URI contains the ssh connection URLs to be used by the client
URI []*url.URL URI []*url.URL
// Iden contains ssh identity keys to be used by the client // Identities contains ssh identity keys to be used by the client
Iden []string Identities []string
// Save Options used for first half of the scp operation
Save ImageSaveOptions
// Load options used for the second half of the scp operation
Load ImageLoadOptions
// Rootless determines whether we are loading locally from root storage to rootless storage
Rootless bool
// User is used in conjunction with Rootless to determine which user to use to obtain the uid
User string
} }
// ImageTreeOptions provides options for ImageEngine.Tree() // ImageTreeOptions provides options for ImageEngine.Tree()

View File

@ -28,6 +28,7 @@ import (
domainUtils "github.com/containers/podman/v3/pkg/domain/utils" domainUtils "github.com/containers/podman/v3/pkg/domain/utils"
"github.com/containers/podman/v3/pkg/errorhandling" "github.com/containers/podman/v3/pkg/errorhandling"
"github.com/containers/podman/v3/pkg/rootless" "github.com/containers/podman/v3/pkg/rootless"
"github.com/containers/podman/v3/utils"
"github.com/containers/storage" "github.com/containers/storage"
dockerRef "github.com/docker/distribution/reference" dockerRef "github.com/docker/distribution/reference"
"github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
@ -351,65 +352,19 @@ func (ir *ImageEngine) Push(ctx context.Context, source string, destination stri
return pushError return pushError
} }
// Transfer moves images from root to rootless storage so the user specified in the scp call can access and use the image modified by root // Transfer moves images between root and rootless storage so the user specified in the scp call can access and use the image modified by root
func (ir *ImageEngine) Transfer(ctx context.Context, scpOpts entities.ImageScpOptions) error { func (ir *ImageEngine) Transfer(ctx context.Context, source entities.ImageScpOptions, dest entities.ImageScpOptions, parentFlags []string) error {
if scpOpts.User == "" { if source.User == "" {
return errors.Wrapf(define.ErrInvalidArg, "you must define a user when transferring from root to rootless storage") return errors.Wrapf(define.ErrInvalidArg, "you must define a user when transferring from root to rootless storage")
} }
var u *user.User
scpOpts.User = strings.Split(scpOpts.User, ":")[0] // split in case provided with uid:gid
_, err := strconv.Atoi(scpOpts.User)
if err != nil {
u, err = user.Lookup(scpOpts.User)
if err != nil {
return err
}
} else {
u, err = user.LookupId(scpOpts.User)
if err != nil {
return err
}
}
uid, err := strconv.Atoi(u.Uid)
if err != nil {
return err
}
gid, err := strconv.Atoi(u.Gid)
if err != nil {
return err
}
err = os.Chown(scpOpts.Save.Output, uid, gid) // chown the output because was created by root so we need to give th euser read access
if err != nil {
return err
}
podman, err := os.Executable() podman, err := os.Executable()
if err != nil { if err != nil {
return err return err
} }
machinectl, err := exec.LookPath("machinectl") if rootless.IsRootless() && (len(dest.User) == 0 || dest.User == "root") { // if we are rootless and do not have a destination user we can just use sudo
if err != nil { return transferRootless(source, dest, podman, parentFlags)
logrus.Warn("defaulting to su since machinectl is not available, su will fail if no user session is available")
cmd := exec.Command("su", "-l", u.Username, "--command", podman+" --log-level="+logrus.GetLevel().String()+" --cgroup-manager=cgroupfs load --input="+scpOpts.Save.Output) // load the new image to the rootless storage
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
logrus.Debug("Executing load command su")
err = cmd.Run()
if err != nil {
return err
} }
} else { return transferRootful(source, dest, podman, parentFlags)
cmd := exec.Command(machinectl, "shell", "-q", u.Username+"@.host", podman, "--log-level="+logrus.GetLevel().String(), "--cgroup-manager=cgroupfs", "load", "--input", scpOpts.Save.Output) // load the new image to the rootless storage
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
logrus.Debug("Executing load command machinectl")
err = cmd.Run()
if err != nil {
return err
}
}
return nil
} }
func (ir *ImageEngine) Tag(ctx context.Context, nameOrID string, tags []string, options entities.ImageTagOptions) error { func (ir *ImageEngine) Tag(ctx context.Context, nameOrID string, tags []string, options entities.ImageTagOptions) error {
@ -786,3 +741,123 @@ func putSignature(manifestBlob []byte, mech signature.SigningMechanism, sigStore
} }
return nil return nil
} }
// TransferRootless creates new podman processes using exec.Command and sudo, transferring images between the given source and destination users
func transferRootless(source entities.ImageScpOptions, dest entities.ImageScpOptions, podman string, parentFlags []string) error {
var cmdSave *exec.Cmd
saveCommand := parentFlags
saveCommand = append(saveCommand, []string{"save", "--output", source.File, source.Image}...)
loadCommand := parentFlags
loadCommand = append(loadCommand, []string{"load", "--input", dest.File}...)
if source.User == "root" {
cmdSave = exec.Command("sudo", podman)
} else {
cmdSave = exec.Command(podman)
}
cmdSave = utils.CreateSCPCommand(cmdSave, saveCommand)
logrus.Debug("Executing save command")
err := cmdSave.Run()
if err != nil {
return err
}
var cmdLoad *exec.Cmd
if source.User != "root" {
cmdLoad = exec.Command("sudo", podman)
} else {
cmdLoad = exec.Command(podman)
}
cmdLoad = utils.CreateSCPCommand(cmdLoad, loadCommand)
logrus.Debug("Executing load command")
err = cmdLoad.Run()
if err != nil {
return err
}
return nil
}
// TransferRootless creates new podman processes using exec.Command and su/machinectl, transferring images between the given source and destination users
func transferRootful(source entities.ImageScpOptions, dest entities.ImageScpOptions, podman string, parentFlags []string) error {
basicCommand := []string{podman}
basicCommand = append(basicCommand, parentFlags...)
saveCommand := append(basicCommand, []string{"save", "--output", source.File, source.Image}...)
loadCommand := append(basicCommand, []string{"load", "--input", dest.File}...)
save := []string{strings.Join(saveCommand, " ")}
load := []string{strings.Join(loadCommand, " ")}
// if executing using sudo or transferring between two users, the TransferRootless approach will not work, default to using machinectl or su as necessary.
// the approach using sudo is preferable and more straightforward. There is no reason for using sudo in these situations
// since the feature is meant to transfer from root to rootless an vice versa without explicit sudo evocaiton.
var uSave *user.User
var uLoad *user.User
var err error
source.User = strings.Split(source.User, ":")[0] // split in case provided with uid:gid
dest.User = strings.Split(dest.User, ":")[0]
uSave, err = lookupUser(source.User)
if err != nil {
return err
}
switch {
case dest.User != "": // if we are given a destination user, check that first
uLoad, err = lookupUser(dest.User)
if err != nil {
return err
}
case uSave.Name != "root": // else if we have no destination user, and source is not root that means we should be root
uLoad, err = user.LookupId("0")
if err != nil {
return err
}
default: // else if we have no dest user, and source user IS root, we want to be the default user.
uString := os.Getenv("SUDO_USER")
if uString == "" {
return errors.New("$SUDO_USER must be defined to find the default rootless user")
}
uLoad, err = user.Lookup(uString)
if err != nil {
return err
}
}
machinectl, err := exec.LookPath("machinectl")
if err != nil {
logrus.Warn("defaulting to su since machinectl is not available, su will fail if no user session is available")
err = execSu(uSave, save)
if err != nil {
return err
}
return execSu(uLoad, load)
}
err = execMachine(uSave, saveCommand, machinectl)
if err != nil {
return err
}
return execMachine(uLoad, loadCommand, machinectl)
}
func lookupUser(u string) (*user.User, error) {
if u, err := user.LookupId(u); err == nil {
return u, nil
}
return user.Lookup(u)
}
func execSu(execUser *user.User, command []string) error {
cmd := exec.Command("su", "-l", execUser.Username, "--command")
cmd = utils.CreateSCPCommand(cmd, command)
logrus.Debug("Executing command su")
return cmd.Run()
}
func execMachine(execUser *user.User, command []string, machinectl string) error {
var cmd *exec.Cmd
if execUser.Uid == "0" {
cmd = exec.Command("sudo", machinectl, "shell", "-q", execUser.Username+"@.host")
} else {
cmd = exec.Command(machinectl, "shell", "-q", execUser.Username+"@.host")
}
cmd = utils.CreateSCPCommand(cmd, command)
logrus.Debug("Executing command machinectl")
return cmd.Run()
}

View File

@ -123,7 +123,7 @@ func (ir *ImageEngine) Pull(ctx context.Context, rawImage string, opts entities.
return &entities.ImagePullReport{Images: pulledImages}, nil return &entities.ImagePullReport{Images: pulledImages}, nil
} }
func (ir *ImageEngine) Transfer(ctx context.Context, scpOpts entities.ImageScpOptions) error { func (ir *ImageEngine) Transfer(ctx context.Context, source entities.ImageScpOptions, dest entities.ImageScpOptions, parentFlags []string) error {
return errors.Wrapf(define.ErrNotImplemented, "cannot use the remote client to transfer images between root and rootless storage") return errors.Wrapf(define.ErrNotImplemented, "cannot use the remote client to transfer images between root and rootless storage")
} }

View File

@ -244,7 +244,7 @@ can_use_shortcut ()
if (argv[argc+1] != NULL && (strcmp (argv[argc], "container") == 0 || if (argv[argc+1] != NULL && (strcmp (argv[argc], "container") == 0 ||
strcmp (argv[argc], "image") == 0) && strcmp (argv[argc], "image") == 0) &&
strcmp (argv[argc+1], "mount") == 0) (strcmp (argv[argc+1], "mount") == 0 || strcmp (argv[argc+1], "scp") == 0))
{ {
ret = false; ret = false;
break; break;

View File

@ -29,7 +29,6 @@ var _ = Describe("podman image scp", func() {
panic(err) panic(err)
} }
os.Setenv("CONTAINERS_CONF", conf.Name()) os.Setenv("CONTAINERS_CONF", conf.Name())
tempdir, err = CreateTempDirInTempDir() tempdir, err = CreateTempDirInTempDir()
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
@ -52,38 +51,6 @@ var _ = Describe("podman image scp", func() {
}) })
It("podman image scp quiet flag", func() {
if IsRemote() {
Skip("this test is only for non-remote")
}
scp := podmanTest.Podman([]string{"image", "scp", "-q", ALPINE})
scp.WaitWithDefaultTimeout()
Expect(scp).To(Exit(0))
})
It("podman image scp root to rootless transfer", func() {
SkipIfNotRootless("this is a rootless only test, transferring from root to rootless using PodmanAsUser")
if IsRemote() {
Skip("this test is only for non-remote")
}
env := os.Environ()
img := podmanTest.PodmanAsUser([]string{"image", "pull", ALPINE}, 0, 0, "", env) // pull image to root
img.WaitWithDefaultTimeout()
Expect(img).To(Exit(0))
scp := podmanTest.PodmanAsUser([]string{"image", "scp", "root@localhost::" + ALPINE, "1000:1000@localhost::"}, 0, 0, "", env) //transfer from root to rootless (us)
scp.WaitWithDefaultTimeout()
Expect(scp).To(Exit(0))
list := podmanTest.Podman([]string{"image", "list"}) // our image should now contain alpine loaded in from root
list.WaitWithDefaultTimeout()
Expect(list).To(Exit(0))
Expect(list.OutputToStringArray()).To(ContainElement(HavePrefix("quay.io/libpod/alpine")))
scp = podmanTest.PodmanAsUser([]string{"image", "scp", "root@localhost::" + ALPINE}, 0, 0, "", env) //transfer from root to rootless (us)
scp.WaitWithDefaultTimeout()
Expect(scp).To(Exit(0))
})
It("podman image scp bogus image", func() { It("podman image scp bogus image", func() {
if IsRemote() { if IsRemote() {
Skip("this test is only for non-remote") Skip("this test is only for non-remote")
@ -119,11 +86,8 @@ var _ = Describe("podman image scp", func() {
scp.Wait(45) scp.Wait(45)
// exit with error because we cannot make an actual ssh connection // exit with error because we cannot make an actual ssh connection
// This tests that the input we are given is validated and prepared correctly // This tests that the input we are given is validated and prepared correctly
// Error: failed to connect: dial tcp: address foo: missing port in address // The error given should either be a missing image (due to testing suite complications) or a i/o timeout on ssh
Expect(scp).To(ExitWithError()) Expect(scp).To(ExitWithError())
Expect(scp.ErrorToString()).To(ContainSubstring(
"Error: failed to connect: dial tcp 66.151.147.142:2222: i/o timeout",
))
}) })

View File

@ -78,6 +78,35 @@ verify_iid_and_name() {
run_podman rmi $fqin run_podman rmi $fqin
} }
@test "podman image scp transfer" {
skip_if_root_ubuntu "cannot create a new user successfully on ubuntu"
get_iid_and_name
if ! is_remote; then
if is_rootless; then
whoami=$(id -un)
run_podman image scp $whoami@localhost::$iid root@localhost::
if [ "$status" -ne 0 ]; then
die "Command failed: podman image scp transfer"
fi
whoami=$(id -un)
run_podman image scp -q $whoami@localhost::$iid root@localhost::
if [ "$status" -ne 0 ]; then
die "Command failed: podman image scp quiet transfer failed"
fi
fi
if ! is_rootless; then
id -u 1000 &>/dev/null || useradd -u 1000 -g 1000 testingUsr
if [ "$status" -ne 0 ]; then
die "Command failed: useradd 1000"
fi
run_podman image scp root@localhost::$iid 1000:1000@localhost::
if [ "$status" -ne 0 ]; then
die "Command failed: podman image scp transfer"
fi
fi
fi
}
@test "podman load - by image ID" { @test "podman load - by image ID" {
# FIXME: how to build a simple archive instead? # FIXME: how to build a simple archive instead?

View File

@ -11,7 +11,7 @@ function setup() {
# TL;DR they keep fixing it then breaking it again. There's a test we # TL;DR they keep fixing it then breaking it again. There's a test we
# could run to see if it's fixed, but it's way too complicated. Since # could run to see if it's fixed, but it's way too complicated. Since
# integration tests also skip checkpoint tests on Ubuntu, do the same here. # integration tests also skip checkpoint tests on Ubuntu, do the same here.
if grep -qiw ubuntu /etc/os-release; then if is_ubuntu; then
skip "FIXME: checkpointing broken in Ubuntu 2004, 2104, 2110, ..." skip "FIXME: checkpointing broken in Ubuntu 2004, 2104, 2110, ..."
fi fi

View File

@ -318,6 +318,10 @@ function wait_for_port() {
# BEGIN miscellaneous tools # BEGIN miscellaneous tools
# Shortcuts for common needs: # Shortcuts for common needs:
function is_ubuntu() {
grep -qiw ubuntu /etc/os-release
}
function is_rootless() { function is_rootless() {
[ "$(id -u)" -ne 0 ] [ "$(id -u)" -ne 0 ]
} }
@ -449,6 +453,16 @@ function skip_if_journald_unavailable {
fi fi
} }
function skip_if_root_ubuntu {
if is_ubuntu; then
if ! is_remote; then
if ! is_rootless; then
skip "Cannot run this test on rootful ubuntu, usually due to user errors"
fi
fi
fi
}
######### #########
# die # Abort with helpful message # die # Abort with helpful message
######### #########

View File

@ -224,3 +224,12 @@ func MovePauseProcessToScope(pausePidPath string) {
} }
} }
} }
// CreateSCPCommand takes an existing command, appends the given arguments and returns a configured podman command for image scp
func CreateSCPCommand(cmd *exec.Cmd, command []string) *exec.Cmd {
cmd.Args = append(cmd.Args, command...)
cmd.Env = os.Environ()
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
return cmd
}