Merge pull request #25397 from Luap99/artifact-mount

add artifact mount support
This commit is contained in:
openshift-merge-bot[bot] 2025-03-13 12:53:34 +00:00 committed by GitHub
commit 6e34514553
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 666 additions and 79 deletions

View File

@ -6,12 +6,12 @@
Attach a filesystem mount to the container
Current supported mount TYPEs are **bind**, **devpts**, **glob**, **image**, **ramfs**, **tmpfs** and **volume**.
Current supported mount TYPEs are **artifact**, **bind**, **devpts**, **glob**, **image**, **ramfs**, **tmpfs** and **volume**.
Options common to all mount types:
- *src*, *source*: mount source spec for **bind**, **glob**, and **volume**.
Mandatory for **bind** and **glob**.
Mandatory for **artifact**, **bind**, **glob**, **image** and **volume**.
- *dst*, *destination*, *target*: mount destination spec.
@ -24,6 +24,25 @@ on the destination directory are mounted. The option
to mount host files matching /foo* to the /tmp/bar/
directory in the container.
Options specific to type=**artifact**:
- *digest*: If the artifact source contains multiple blobs a digest can be
specified to only mount the one specific blob with the digest.
- *title*: If the artifact source contains multiple blobs a title can be set
which is compared against `org.opencontainers.image.title` annotation.
The *src* argument contains the name of the artifact, it must already exist locally.
The *dst* argument contains the target path, if the path in the container is a
directory or does not exist the blob title (`org.opencontainers.image.title`
annotation) will be used as filename and joined to the path. If the annotation
does not exist the digest will be used as filename instead. This results in all blobs
of the artifact mounted into the container at the given path.
However if the *dst* path is a existing file in the container then the blob will be
mounted directly on it. This only works when the artifact contains of a single blob
or when either *digest* or *title* are specified.
Options specific to type=**volume**:
- *ro*, *readonly*: *true* or *false* (default if unspecified: *false*).
@ -104,4 +123,6 @@ Examples:
- `type=tmpfs,destination=/path/in/container,noswap`
- `type=volume,source=vol1,destination=/path/in/container,ro=true`
- `type=artifact,src=quay.io/libpod/testartifact:20250206-single,dst=/data`
- `type=artifact,src=quay.io/libpod/testartifact:20250206-multi,dst=/data,title=test1`

View File

@ -280,6 +280,28 @@ type ContainerImageVolume struct {
SubPath string `json:"subPath,omitempty"`
}
// ContainerArtifactVolume is a volume based on a artifact. The artifact blobs will
// be bind mounted directly as files and must always be read only.
type ContainerArtifactVolume struct {
// Source is the name or digest of the artifact that should be mounted
Source string `json:"source"`
// Dest is the absolute path of the mount in the container.
// If path is a file in the container, then the artifact must consist of a single blob.
// Otherwise if it is a directory or does not exists all artifact blobs will be mounted
// into this path as files. As name the "org.opencontainers.image.title" will be used if
// available otherwise the digest is used as name.
Dest string `json:"dest"`
// Title can be used for multi blob artifacts to only mount the one specific blob that
// matches the "org.opencontainers.image.title" annotation.
// Optional. Conflicts with Digest.
Title string `json:"title"`
// Digest can be used to filter a single blob from a multi blob artifact by the given digest.
// When this option is set the file name in the container defaults to the digest even when
// the title annotation exist.
// Optional. Conflicts with Title.
Digest string `json:"digest"`
}
// ContainerSecret is a secret that is mounted in a container
type ContainerSecret struct {
// Secret is the secret

View File

@ -162,6 +162,8 @@ type ContainerRootFSConfig struct {
// moved out of Libpod into pkg/specgen).
// Please DO NOT reuse the `imageVolumes` name in container JSON again.
ImageVolumes []*ContainerImageVolume `json:"ctrImageVolumes,omitempty"`
// ArtifactVolumes lists the artifact volumes to mount into the container.
ArtifactVolumes []*ContainerArtifactVolume `json:"artifactVolumes,omitempty"`
// CreateWorkingDir indicates that Libpod should create the container's
// working directory if it does not exist. Some OCI runtimes do this by
// default, but others do not.

View File

@ -41,6 +41,7 @@ import (
"github.com/containers/podman/v5/pkg/annotations"
"github.com/containers/podman/v5/pkg/checkpoint/crutils"
"github.com/containers/podman/v5/pkg/criu"
libartTypes "github.com/containers/podman/v5/pkg/libartifact/types"
"github.com/containers/podman/v5/pkg/lookup"
"github.com/containers/podman/v5/pkg/rootless"
"github.com/containers/podman/v5/pkg/util"
@ -483,6 +484,52 @@ func (c *Container) generateSpec(ctx context.Context) (s *spec.Spec, cleanupFunc
g.AddMount(overlayMount)
}
if len(c.config.ArtifactVolumes) > 0 {
artStore, err := c.runtime.ArtifactStore()
if err != nil {
return nil, nil, err
}
for _, artifactMount := range c.config.ArtifactVolumes {
paths, err := artStore.BlobMountPaths(ctx, artifactMount.Source, &libartTypes.BlobMountPathOptions{
FilterBlobOptions: libartTypes.FilterBlobOptions{
Title: artifactMount.Title,
Digest: artifactMount.Digest,
},
})
if err != nil {
return nil, nil, err
}
// Ignore the error, destIsFile will return false with errors so if the file does not exist
// we treat it as dir, the oci runtime will always create the target bind mount path.
destIsFile, _ := containerPathIsFile(c.state.Mountpoint, artifactMount.Dest)
if destIsFile && len(paths) > 1 {
return nil, nil, fmt.Errorf("artifact %q contains more than one blob and container path %q is a file", artifactMount.Source, artifactMount.Dest)
}
for _, path := range paths {
var dest string
if destIsFile {
dest = artifactMount.Dest
} else {
dest = filepath.Join(artifactMount.Dest, path.Name)
}
logrus.Debugf("Mounting artifact %q in container %s, mount blob %q to %q", artifactMount.Source, c.ID(), path.SourcePath, dest)
g.AddMount(spec.Mount{
Destination: dest,
Source: path.SourcePath,
Type: define.TypeBind,
// Important: This must always be mounted read only here, we are using
// the source in the artifact store directly and because that is digest
// based a write will break the layout.
Options: []string{define.TypeBind, "ro"},
})
}
}
}
err = c.setHomeEnvIfNeeded()
if err != nil {
return nil, nil, err

View File

@ -13,6 +13,7 @@ import (
"github.com/containers/common/libnetwork/types"
"github.com/containers/podman/v5/pkg/rootless"
securejoin "github.com/cyphar/filepath-securejoin"
spec "github.com/opencontainers/runtime-spec/specs-go"
"github.com/opencontainers/runtime-tools/generate"
"github.com/sirupsen/logrus"
@ -415,3 +416,20 @@ func (c *Container) hasPrivateUTS() bool {
func hasCapSysResource() (bool, error) {
return true, nil
}
// containerPathIsFile returns true if the given containerPath is a file
func containerPathIsFile(unsafeRoot string, containerPath string) (bool, error) {
// Note freebsd does not have support for OpenInRoot() so us the less safe way
// with the old SecureJoin(), but given this is only called before the container
// is started it is not subject to race conditions with the container process.
path, err := securejoin.SecureJoin(unsafeRoot, containerPath)
if err != nil {
return false, err
}
st, err := os.Lstat(path)
if err == nil && !st.IsDir() {
return true, nil
}
return false, err
}

View File

@ -21,6 +21,7 @@ import (
"github.com/containers/podman/v5/libpod/define"
"github.com/containers/podman/v5/libpod/shutdown"
"github.com/containers/podman/v5/pkg/rootless"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/moby/sys/capability"
spec "github.com/opencontainers/runtime-spec/specs-go"
"github.com/opencontainers/runtime-tools/generate"
@ -848,3 +849,18 @@ var hasCapSysResource = sync.OnceValues(func() (bool, error) {
}
return currentCaps.Get(capability.EFFECTIVE, capability.CAP_SYS_RESOURCE), nil
})
// containerPathIsFile returns true if the given containerPath is a file
func containerPathIsFile(unsafeRoot string, containerPath string) (bool, error) {
f, err := securejoin.OpenInRoot(unsafeRoot, containerPath)
if err != nil {
return false, err
}
defer f.Close()
st, err := f.Stat()
if err == nil && !st.IsDir() {
return true, nil
}
return false, err
}

View File

@ -1515,6 +1515,19 @@ func WithImageVolumes(volumes []*ContainerImageVolume) CtrCreateOption {
}
}
// WithImageVolumes adds the given image volumes to the container.
func WithArtifactVolumes(volumes []*ContainerArtifactVolume) CtrCreateOption {
return func(ctr *Container) error {
if ctr.valid {
return define.ErrCtrFinalized
}
ctr.config.ArtifactVolumes = volumes
return nil
}
}
// WithHealthCheck adds the healthcheck to the container config
func WithHealthCheck(healthCheck *manifest.Schema2HealthConfig) CtrCreateOption {
return func(ctr *Container) error {

View File

@ -34,6 +34,7 @@ import (
"github.com/containers/podman/v5/libpod/shutdown"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/domain/entities/reports"
artStore "github.com/containers/podman/v5/pkg/libartifact/store"
"github.com/containers/podman/v5/pkg/rootless"
"github.com/containers/podman/v5/pkg/systemd"
"github.com/containers/podman/v5/pkg/util"
@ -83,6 +84,9 @@ type Runtime struct {
libimageEventsShutdown chan bool
lockManager lock.Manager
// ArtifactStore returns the artifact store created from the runtime.
ArtifactStore func() (*artStore.ArtifactStore, error)
// Worker
workerChannel chan func()
workerGroup sync.WaitGroup
@ -533,6 +537,11 @@ func makeRuntime(ctx context.Context, runtime *Runtime) (retErr error) {
}
runtime.config.Network.NetworkBackend = string(netBackend)
runtime.network = netInterface
// Using sync once value to only init the store exactly once and only when it will be actually be used.
runtime.ArtifactStore = sync.OnceValues(func() (*artStore.ArtifactStore, error) {
return artStore.NewArtifactStore(filepath.Join(runtime.storageConfig.GraphRoot, "artifacts"), runtime.SystemContext())
})
}
// We now need to see if the system has restarted

View File

@ -5,22 +5,16 @@ package abi
import (
"context"
"os"
"path/filepath"
"time"
"github.com/containers/common/libimage"
"github.com/containers/podman/v5/pkg/domain/entities"
"github.com/containers/podman/v5/pkg/libartifact/store"
"github.com/containers/podman/v5/pkg/libartifact/types"
"github.com/opencontainers/go-digest"
)
func getDefaultArtifactStore(ir *ImageEngine) string {
return filepath.Join(ir.Libpod.StorageConfig().GraphRoot, "artifacts")
}
func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, _ entities.ArtifactInspectOptions) (*entities.ArtifactInspectReport, error) {
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -41,7 +35,7 @@ func (ir *ImageEngine) ArtifactInspect(ctx context.Context, name string, _ entit
func (ir *ImageEngine) ArtifactList(ctx context.Context, _ entities.ArtifactListOptions) ([]*entities.ArtifactListReport, error) {
reports := make([]*entities.ArtifactListReport, 0)
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -80,7 +74,7 @@ func (ir *ImageEngine) ArtifactPull(ctx context.Context, name string, opts entit
if !opts.Quiet && pullOptions.Writer == nil {
pullOptions.Writer = os.Stderr
}
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -91,8 +85,7 @@ func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, opts entitie
var (
namesOrDigests []string
)
artifactDigests := make([]*digest.Digest, 0, len(namesOrDigests))
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -117,6 +110,7 @@ func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, opts entitie
namesOrDigests = append(namesOrDigests, name)
}
artifactDigests := make([]*digest.Digest, 0, len(namesOrDigests))
for _, namesOrDigest := range namesOrDigests {
artifactDigest, err := artStore.Remove(ctx, namesOrDigest)
if err != nil {
@ -133,7 +127,7 @@ func (ir *ImageEngine) ArtifactRm(ctx context.Context, name string, opts entitie
func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entities.ArtifactPushOptions) (*entities.ArtifactPushReport, error) {
var retryDelay *time.Duration
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -189,7 +183,7 @@ func (ir *ImageEngine) ArtifactPush(ctx context.Context, name string, opts entit
return &entities.ArtifactPushReport{}, err
}
func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []string, opts *entities.ArtifactAddOptions) (*entities.ArtifactAddReport, error) {
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return nil, err
}
@ -210,13 +204,15 @@ func (ir *ImageEngine) ArtifactAdd(ctx context.Context, name string, paths []str
}
func (ir *ImageEngine) ArtifactExtract(ctx context.Context, name string, target string, opts *entities.ArtifactExtractOptions) error {
artStore, err := store.NewArtifactStore(getDefaultArtifactStore(ir), ir.Libpod.SystemContext())
artStore, err := ir.Libpod.ArtifactStore()
if err != nil {
return err
}
extractOpt := &types.ExtractOptions{
Digest: opts.Digest,
Title: opts.Title,
FilterBlobOptions: types.FilterBlobOptions{
Digest: opts.Digest,
Title: opts.Title,
},
}
return artStore.Extract(ctx, name, target, extractOpt)

View File

@ -47,6 +47,10 @@ func NewArtifactStore(storePath string, sc *types.SystemContext) (*ArtifactStore
if storePath == "" {
return nil, errors.New("store path cannot be empty")
}
if !filepath.IsAbs(storePath) {
return nil, fmt.Errorf("store path %q must be absolute", storePath)
}
logrus.Debugf("Using artifact store path: %s", storePath)
artifactStore := &ArtifactStore{
@ -312,23 +316,22 @@ func (as ArtifactStore) Add(ctx context.Context, dest string, paths []string, op
return &artifactManifestDigest, nil
}
// Inspect an artifact in a local store
func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target string, options *libartTypes.ExtractOptions) error {
func getArtifactAndImageSource(ctx context.Context, as ArtifactStore, nameOrDigest string, options *libartTypes.FilterBlobOptions) (*libartifact.Artifact, types.ImageSource, error) {
if len(options.Digest) > 0 && len(options.Title) > 0 {
return errors.New("cannot specify both digest and title")
return nil, nil, errors.New("cannot specify both digest and title")
}
if len(nameOrDigest) == 0 {
return ErrEmptyArtifactName
return nil, nil, ErrEmptyArtifactName
}
artifacts, err := as.getArtifacts(ctx, nil)
if err != nil {
return err
return nil, nil, err
}
arty, nameIsDigest, err := artifacts.GetByNameOrDigest(nameOrDigest)
if err != nil {
return err
return nil, nil, err
}
name := nameOrDigest
if nameIsDigest {
@ -336,14 +339,70 @@ func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target
}
if len(arty.Manifest.Layers) == 0 {
return fmt.Errorf("the artifact has no blobs, nothing to extract")
return nil, nil, fmt.Errorf("the artifact has no blobs, nothing to extract")
}
ir, err := layout.NewReference(as.storePath, name)
if err != nil {
return err
return nil, nil, err
}
imgSrc, err := ir.NewImageSource(ctx, as.SystemContext)
return arty, imgSrc, err
}
// BlobMountPaths allows the caller to access the file names from the store and how they should be mounted.
func (as ArtifactStore) BlobMountPaths(ctx context.Context, nameOrDigest string, options *libartTypes.BlobMountPathOptions) ([]libartTypes.BlobMountPath, error) {
arty, imgSrc, err := getArtifactAndImageSource(ctx, as, nameOrDigest, &options.FilterBlobOptions)
if err != nil {
return nil, err
}
defer imgSrc.Close()
if len(options.Digest) > 0 || len(options.Title) > 0 {
digest, err := findDigest(arty, &options.FilterBlobOptions)
if err != nil {
return nil, err
}
// In case the digest is set we always use it as target name
// so we do not have to get the actual title annotation form the blob.
// Passing options.Title is enough because we know it is empty when digest
// is set as we only allow either one.
filename, err := generateArtifactBlobName(options.Title, digest)
if err != nil {
return nil, err
}
path, err := layout.GetLocalBlobPath(ctx, imgSrc, digest)
if err != nil {
return nil, err
}
return []libartTypes.BlobMountPath{{
SourcePath: path,
Name: filename,
}}, nil
}
mountPaths := make([]libartTypes.BlobMountPath, 0, len(arty.Manifest.Layers))
for _, l := range arty.Manifest.Layers {
title := l.Annotations[specV1.AnnotationTitle]
filename, err := generateArtifactBlobName(title, l.Digest)
if err != nil {
return nil, err
}
path, err := layout.GetLocalBlobPath(ctx, imgSrc, l.Digest)
if err != nil {
return nil, err
}
mountPaths = append(mountPaths, libartTypes.BlobMountPath{
SourcePath: path,
Name: filename,
})
}
return mountPaths, nil
}
// Extract an artifact to local file or directory
func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target string, options *libartTypes.ExtractOptions) error {
arty, imgSrc, err := getArtifactAndImageSource(ctx, as, nameOrDigest, &options.FilterBlobOptions)
if err != nil {
return err
}
@ -364,7 +423,7 @@ func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target
if len(options.Digest) == 0 && len(options.Title) == 0 {
return fmt.Errorf("the artifact consists of several blobs and the target %q is not a directory and neither digest or title was specified to only copy a single blob", target)
}
digest, err = findDigest(arty, options)
digest, err = findDigest(arty, &options.FilterBlobOptions)
if err != nil {
return err
}
@ -376,7 +435,7 @@ func (as ArtifactStore) Extract(ctx context.Context, nameOrDigest string, target
}
if len(options.Digest) > 0 || len(options.Title) > 0 {
digest, err := findDigest(arty, options)
digest, err := findDigest(arty, &options.FilterBlobOptions)
if err != nil {
return err
}
@ -427,7 +486,7 @@ func generateArtifactBlobName(title string, digest digest.Digest) (string, error
return filename, nil
}
func findDigest(arty *libartifact.Artifact, options *libartTypes.ExtractOptions) (digest.Digest, error) {
func findDigest(arty *libartifact.Artifact, options *libartTypes.FilterBlobOptions) (digest.Digest, error) {
var digest digest.Digest
for _, l := range arty.Manifest.Layers {
if options.Digest == l.Digest.String() {

View File

@ -12,9 +12,28 @@ type AddOptions struct {
Append bool `json:",omitempty"`
}
type ExtractOptions struct {
// Title annotation value to extract only a single blob matching that name. Optional.
// FilterBlobOptions options used to filter for a single blob in an artifact
type FilterBlobOptions struct {
// Title annotation value to extract only a single blob matching that name.
// Optional. Conflicts with Digest.
Title string
// Digest of the blob to extract. Optional.
// Digest of the blob to extract.
// Optional. Conflicts with Title.
Digest string
}
type ExtractOptions struct {
FilterBlobOptions
}
type BlobMountPathOptions struct {
FilterBlobOptions
}
// BlobMountPath contains the info on how the artifact must be mounted
type BlobMountPath struct {
// Source path of the blob, i.e. full path in the blob dir.
SourcePath string
// Name of the file in the container.
Name string
}

View File

@ -507,6 +507,19 @@ func createContainerOptions(rt *libpod.Runtime, s *specgen.SpecGenerator, pod *l
options = append(options, libpod.WithImageVolumes(vols))
}
if len(s.ArtifactVolumes) != 0 {
vols := make([]*libpod.ContainerArtifactVolume, 0, len(s.ArtifactVolumes))
for _, v := range s.ArtifactVolumes {
vols = append(vols, &libpod.ContainerArtifactVolume{
Dest: v.Destination,
Source: v.Source,
Digest: v.Digest,
Title: v.Title,
})
}
options = append(options, libpod.WithArtifactVolumes(vols))
}
if s.Command != nil {
options = append(options, libpod.WithCommand(s.Command))
}

View File

@ -305,6 +305,8 @@ type ContainerStorageConfig struct {
// Image volumes bind-mount a container-image mount into the container.
// Optional.
ImageVolumes []*ImageVolume `json:"image_volumes,omitempty"`
// ArtifactVolumes volumes based on an existing artifact.
ArtifactVolumes []*ArtifactVolume `json:"artifact_volumes,omitempty"`
// Devices are devices that will be added to the container.
// Optional.
Devices []spec.LinuxDevice `json:"devices,omitempty"`

View File

@ -58,6 +58,28 @@ type ImageVolume struct {
SubPath string `json:"subPath,omitempty"`
}
// ArtifactVolume is a volume based on a artifact. The artifact blobs will
// be bind mounted directly as files and must always be read only.
type ArtifactVolume struct {
// Source is the name or digest of the artifact that should be mounted
Source string `json:"source"`
// Destination is the absolute path of the mount in the container.
// If path is a file in the container, then the artifact must consist of a single blob.
// Otherwise if it is a directory or does not exists all artifact blobs will be mounted
// into this path as files. As name the "org.opencontainers.image.title" will be used if
// available otherwise the digest is used as name.
Destination string `json:"destination"`
// Title can be used for multi blob artifacts to only mount the one specific blob that
// matches the "org.opencontainers.image.title" annotation.
// Optional. Conflicts with Digest.
Title string `json:"title,omitempty"`
// Digest can be used to filter a single blob from a multi blob artifact by the given digest.
// When this option is set the file name in the container defaults to the digest even when
// the title annotation exist.
// Optional. Conflicts with Title.
Digest string `json:"digest,omitempty"`
}
// GenVolumeMounts parses user input into mounts, volumes and overlay volumes
func GenVolumeMounts(volumeFlag []string) (map[string]spec.Mount, map[string]*NamedVolume, map[string]*OverlayVolume, error) {
mounts := make(map[string]spec.Mount)

View File

@ -762,15 +762,15 @@ func FillOutSpecGen(s *specgen.SpecGenerator, c *entities.ContainerCreateOptions
// Only add read-only tmpfs mounts in case that we are read-only and the
// read-only tmpfs flag has been set.
mounts, volumes, overlayVolumes, imageVolumes, err := parseVolumes(rtc, c.Volume, c.Mount, c.TmpFS)
containerMounts, err := parseVolumes(rtc, c.Volume, c.Mount, c.TmpFS)
if err != nil {
return err
}
if len(s.Mounts) == 0 || len(c.Mount) != 0 {
s.Mounts = mounts
s.Mounts = containerMounts.mounts
}
if len(s.Volumes) == 0 || len(c.Volume) != 0 {
s.Volumes = volumes
s.Volumes = containerMounts.volumes
}
if s.LabelNested != nil && *s.LabelNested {
@ -785,10 +785,13 @@ func FillOutSpecGen(s *specgen.SpecGenerator, c *entities.ContainerCreateOptions
}
// TODO make sure these work in clone
if len(s.OverlayVolumes) == 0 {
s.OverlayVolumes = overlayVolumes
s.OverlayVolumes = containerMounts.overlayVolumes
}
if len(s.ImageVolumes) == 0 {
s.ImageVolumes = imageVolumes
s.ImageVolumes = containerMounts.imageVolumes
}
if len(s.ArtifactVolumes) == 0 {
s.ArtifactVolumes = containerMounts.artifactVolumes
}
devices := c.Devices

View File

@ -22,6 +22,22 @@ var (
errNoDest = errors.New("must set volume destination")
)
type containerMountSlice struct {
mounts []spec.Mount
volumes []*specgen.NamedVolume
overlayVolumes []*specgen.OverlayVolume
imageVolumes []*specgen.ImageVolume
artifactVolumes []*specgen.ArtifactVolume
}
// containerMountMap contains the container mounts with the destination path as map key
type containerMountMap struct {
mounts map[string]spec.Mount
volumes map[string]*specgen.NamedVolume
imageVolumes map[string]*specgen.ImageVolume
artifactVolumes map[string]*specgen.ArtifactVolume
}
type universalMount struct {
mount spec.Mount
// Used only with Named Volume type mounts
@ -34,57 +50,57 @@ type universalMount struct {
// Does not handle image volumes, init, and --volumes-from flags.
// Can also add tmpfs mounts from read-only tmpfs.
// TODO: handle options parsing/processing via containers/storage/pkg/mount
func parseVolumes(rtc *config.Config, volumeFlag, mountFlag, tmpfsFlag []string) ([]spec.Mount, []*specgen.NamedVolume, []*specgen.OverlayVolume, []*specgen.ImageVolume, error) {
func parseVolumes(rtc *config.Config, volumeFlag, mountFlag, tmpfsFlag []string) (*containerMountSlice, error) {
// Get mounts from the --mounts flag.
// TODO: The runtime config part of this needs to move into pkg/specgen/generate to avoid querying containers.conf on the client.
unifiedMounts, unifiedVolumes, unifiedImageVolumes, err := Mounts(mountFlag, rtc.Mounts())
unifiedContainerMounts, err := mounts(mountFlag, rtc.Mounts())
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
// Next --volumes flag.
volumeMounts, volumeVolumes, overlayVolumes, err := specgen.GenVolumeMounts(volumeFlag)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
// Next --tmpfs flag.
tmpfsMounts, err := getTmpfsMounts(tmpfsFlag)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
// Unify mounts from --mount, --volume, --tmpfs.
// Start with --volume.
for dest, mount := range volumeMounts {
if vol, ok := unifiedMounts[dest]; ok {
if vol, ok := unifiedContainerMounts.mounts[dest]; ok {
if mount.Source == vol.Source &&
specgen.StringSlicesEqual(vol.Options, mount.Options) {
continue
}
return nil, nil, nil, nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
return nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
}
unifiedMounts[dest] = mount
unifiedContainerMounts.mounts[dest] = mount
}
for dest, volume := range volumeVolumes {
if vol, ok := unifiedVolumes[dest]; ok {
if vol, ok := unifiedContainerMounts.volumes[dest]; ok {
if volume.Name == vol.Name &&
specgen.StringSlicesEqual(vol.Options, volume.Options) {
continue
}
return nil, nil, nil, nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
return nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
}
unifiedVolumes[dest] = volume
unifiedContainerMounts.volumes[dest] = volume
}
// Now --tmpfs
for dest, tmpfs := range tmpfsMounts {
if vol, ok := unifiedMounts[dest]; ok {
if vol, ok := unifiedContainerMounts.mounts[dest]; ok {
if vol.Type != define.TypeTmpfs {
return nil, nil, nil, nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
return nil, fmt.Errorf("%v: %w", dest, specgen.ErrDuplicateDest)
}
continue
}
unifiedMounts[dest] = tmpfs
unifiedContainerMounts.mounts[dest] = tmpfs
}
// Check for conflicts between named volumes, overlay & image volumes,
@ -97,64 +113,81 @@ func parseVolumes(rtc *config.Config, volumeFlag, mountFlag, tmpfsFlag []string)
allMounts[dest] = true
return nil
}
for dest := range unifiedMounts {
for dest := range unifiedContainerMounts.mounts {
if err := testAndSet(dest); err != nil {
return nil, nil, nil, nil, err
return nil, err
}
}
for dest := range unifiedVolumes {
for dest := range unifiedContainerMounts.volumes {
if err := testAndSet(dest); err != nil {
return nil, nil, nil, nil, err
return nil, err
}
}
for dest := range overlayVolumes {
if err := testAndSet(dest); err != nil {
return nil, nil, nil, nil, err
return nil, err
}
}
for dest := range unifiedImageVolumes {
for dest := range unifiedContainerMounts.imageVolumes {
if err := testAndSet(dest); err != nil {
return nil, nil, nil, nil, err
return nil, err
}
}
for dest := range unifiedContainerMounts.artifactVolumes {
if err := testAndSet(dest); err != nil {
return nil, err
}
}
// Final step: maps to arrays
finalMounts := make([]spec.Mount, 0, len(unifiedMounts))
for _, mount := range unifiedMounts {
finalMounts := make([]spec.Mount, 0, len(unifiedContainerMounts.mounts))
for _, mount := range unifiedContainerMounts.mounts {
if mount.Type == define.TypeBind {
absSrc, err := specgen.ConvertWinMountPath(mount.Source)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("getting absolute path of %s: %w", mount.Source, err)
return nil, fmt.Errorf("getting absolute path of %s: %w", mount.Source, err)
}
mount.Source = absSrc
}
finalMounts = append(finalMounts, mount)
}
finalVolumes := make([]*specgen.NamedVolume, 0, len(unifiedVolumes))
for _, volume := range unifiedVolumes {
finalVolumes := make([]*specgen.NamedVolume, 0, len(unifiedContainerMounts.volumes))
for _, volume := range unifiedContainerMounts.volumes {
finalVolumes = append(finalVolumes, volume)
}
finalOverlayVolume := make([]*specgen.OverlayVolume, 0)
finalOverlayVolume := make([]*specgen.OverlayVolume, 0, len(overlayVolumes))
for _, volume := range overlayVolumes {
finalOverlayVolume = append(finalOverlayVolume, volume)
}
finalImageVolumes := make([]*specgen.ImageVolume, 0, len(unifiedImageVolumes))
for _, volume := range unifiedImageVolumes {
finalImageVolumes := make([]*specgen.ImageVolume, 0, len(unifiedContainerMounts.imageVolumes))
for _, volume := range unifiedContainerMounts.imageVolumes {
finalImageVolumes = append(finalImageVolumes, volume)
}
finalArtifactVolumes := make([]*specgen.ArtifactVolume, 0, len(unifiedContainerMounts.artifactVolumes))
for _, volume := range unifiedContainerMounts.artifactVolumes {
finalArtifactVolumes = append(finalArtifactVolumes, volume)
}
return finalMounts, finalVolumes, finalOverlayVolume, finalImageVolumes, nil
return &containerMountSlice{
mounts: finalMounts,
volumes: finalVolumes,
overlayVolumes: finalOverlayVolume,
imageVolumes: finalImageVolumes,
artifactVolumes: finalArtifactVolumes,
}, nil
}
// Mounts takes user-provided input from the --mount flag as well as Mounts
// mounts takes user-provided input from the --mount flag as well as mounts
// specified in containers.conf and creates OCI spec mounts and Libpod named volumes.
// podman run --mount type=bind,src=/etc/resolv.conf,target=/etc/resolv.conf ...
// podman run --mount type=tmpfs,target=/dev/shm ...
// podman run --mount type=volume,source=test-volume, ...
func Mounts(mountFlag []string, configMounts []string) (map[string]spec.Mount, map[string]*specgen.NamedVolume, map[string]*specgen.ImageVolume, error) {
// podman run --mount type=artifact,source=$artifact,dest=...
func mounts(mountFlag []string, configMounts []string) (*containerMountMap, error) {
finalMounts := make(map[string]spec.Mount)
finalNamedVolumes := make(map[string]*specgen.NamedVolume)
finalImageVolumes := make(map[string]*specgen.ImageVolume)
finalArtifactVolumes := make(map[string]*specgen.ArtifactVolume)
parseMounts := func(mounts []string, ignoreDup bool) error {
for _, mount := range mounts {
// TODO: Docker defaults to "volume" if no mount type is specified.
@ -225,6 +258,18 @@ func Mounts(mountFlag []string, configMounts []string) (map[string]spec.Mount, m
return fmt.Errorf("%v: %w", volume.Destination, specgen.ErrDuplicateDest)
}
finalImageVolumes[volume.Destination] = volume
case "artifact":
volume, err := getArtifactVolume(tokens)
if err != nil {
return err
}
if _, ok := finalArtifactVolumes[volume.Destination]; ok {
if ignoreDup {
continue
}
return fmt.Errorf("%v: %w", volume.Destination, specgen.ErrDuplicateDest)
}
finalArtifactVolumes[volume.Destination] = volume
case "volume":
volume, err := getNamedVolume(tokens)
if err != nil {
@ -246,17 +291,22 @@ func Mounts(mountFlag []string, configMounts []string) (map[string]spec.Mount, m
// Parse mounts passed in from the user
if err := parseMounts(mountFlag, false); err != nil {
return nil, nil, nil, err
return nil, err
}
// If user specified a mount flag that conflicts with a containers.conf flag, then ignore
// the duplicate. This means that the parsing of the containers.conf configMounts should always
// happen second.
if err := parseMounts(configMounts, true); err != nil {
return nil, nil, nil, fmt.Errorf("parsing containers.conf mounts: %w", err)
return nil, fmt.Errorf("parsing containers.conf mounts: %w", err)
}
return finalMounts, finalNamedVolumes, finalImageVolumes, nil
return &containerMountMap{
mounts: finalMounts,
volumes: finalNamedVolumes,
imageVolumes: finalImageVolumes,
artifactVolumes: finalArtifactVolumes,
}, nil
}
func parseMountOptions(mountType string, args []string) (*universalMount, error) {
@ -660,6 +710,50 @@ func getImageVolume(args []string) (*specgen.ImageVolume, error) {
return newVolume, nil
}
// Parse the arguments into an artifact volume. An artifact volume creates mounts
// based on an existing artifact in the store.
func getArtifactVolume(args []string) (*specgen.ArtifactVolume, error) {
newVolume := new(specgen.ArtifactVolume)
for _, arg := range args {
name, value, hasValue := strings.Cut(arg, "=")
switch name {
case "src", "source":
if !hasValue {
return nil, fmt.Errorf("%v: %w", name, errOptionArg)
}
newVolume.Source = value
case "target", "dst", "destination":
if !hasValue {
return nil, fmt.Errorf("%v: %w", name, errOptionArg)
}
if err := parse.ValidateVolumeCtrDir(value); err != nil {
return nil, err
}
newVolume.Destination = unixPathClean(value)
case "title":
if !hasValue {
return nil, fmt.Errorf("%v: %w", name, errOptionArg)
}
newVolume.Title = value
case "digest":
if !hasValue {
return nil, fmt.Errorf("%v: %w", name, errOptionArg)
}
newVolume.Digest = value
default:
return nil, fmt.Errorf("%s: %w", name, util.ErrBadMntOption)
}
}
if len(newVolume.Source)*len(newVolume.Destination) == 0 {
return nil, errors.New("must set source and destination for artifact volume")
}
return newVolume, nil
}
// GetTmpfsMounts creates spec.Mount structs for user-requested tmpfs mounts
func getTmpfsMounts(tmpfsFlag []string) (map[string]spec.Mount, error) {
m := make(map[string]spec.Mount)

View File

@ -0,0 +1,223 @@
//go:build linux || freebsd
package integration
import (
"os"
"path/filepath"
. "github.com/containers/podman/v5/test/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Podman artifact mount", func() {
BeforeEach(func() {
SkipIfRemote("artifacts are not supported on the remote client yet due to being in development still")
})
It("podman artifact mount single blob", func() {
podmanTest.PodmanExitCleanly("artifact", "pull", ARTIFACT_SINGLE)
const artifactContent = "mRuO9ykak1Q2j"
tests := []struct {
name string
mountOpts string
containerFile string
}{
{
name: "single artifact mount",
mountOpts: "dst=/test",
containerFile: "/test/testfile",
},
{
name: "single artifact mount on existing file",
mountOpts: "dst=/etc/os-release",
containerFile: "/etc/os-release",
},
{
name: "single artifact mount with title",
mountOpts: "dst=/tmp,title=testfile",
containerFile: "/tmp/testfile",
},
{
name: "single artifact mount with digest",
mountOpts: "dst=/data,digest=sha256:e9510923578af3632946ecf5ae479c1b5f08b47464e707b5cbab9819272a9752",
containerFile: "/data/sha256-e9510923578af3632946ecf5ae479c1b5f08b47464e707b5cbab9819272a9752",
},
}
for _, tt := range tests {
By(tt.name)
// FIXME: we need https://github.com/containers/container-selinux/pull/360 to fix the selinux access problem, until then disable it.
session := podmanTest.PodmanExitCleanly("run", "--security-opt=label=disable", "--rm", "--mount", "type=artifact,src="+ARTIFACT_SINGLE+","+tt.mountOpts, ALPINE, "cat", tt.containerFile)
Expect(session.OutputToString()).To(Equal(artifactContent))
}
})
It("podman artifact mount multi blob", func() {
podmanTest.PodmanExitCleanly("artifact", "pull", ARTIFACT_MULTI)
podmanTest.PodmanExitCleanly("artifact", "pull", ARTIFACT_MULTI_NO_TITLE)
const (
artifactContent1 = "xuHWedtC0ADST"
artifactContent2 = "tAyZczFlgFsi4"
)
type expectedFiles struct {
file string
content string
}
tests := []struct {
name string
mountOpts string
containerFiles []expectedFiles
}{
{
name: "multi blob with title",
mountOpts: "src=" + ARTIFACT_MULTI + ",dst=/test",
containerFiles: []expectedFiles{
{
file: "/test/test1",
content: artifactContent1,
},
{
file: "/test/test2",
content: artifactContent2,
},
},
},
{
name: "multi blob without title",
mountOpts: "src=" + ARTIFACT_MULTI_NO_TITLE + ",dst=/test",
containerFiles: []expectedFiles{
{
file: "/test/sha256-8257bba28b9d19ac353c4b713b470860278857767935ef7e139afd596cb1bb2d",
content: artifactContent1,
},
{
file: "/test/sha256-63700c54129c6daaafe3a20850079f82d6d658d69de73d6158d81f920c6fbdd7",
content: artifactContent2,
},
},
},
{
name: "multi blob filter by title",
mountOpts: "src=" + ARTIFACT_MULTI + ",dst=/test,title=test2",
containerFiles: []expectedFiles{
{
file: "/test/test2",
content: artifactContent2,
},
},
},
{
name: "multi blob filter by digest",
mountOpts: "src=" + ARTIFACT_MULTI + ",dst=/test,digest=sha256:8257bba28b9d19ac353c4b713b470860278857767935ef7e139afd596cb1bb2d",
containerFiles: []expectedFiles{
{
file: "/test/sha256-8257bba28b9d19ac353c4b713b470860278857767935ef7e139afd596cb1bb2d",
content: artifactContent1,
},
},
},
}
for _, tt := range tests {
By(tt.name)
// FIXME: we need https://github.com/containers/container-selinux/pull/360 to fix the selinux access problem, until then disable it.
args := []string{"run", "--security-opt=label=disable", "--rm", "--mount", "type=artifact," + tt.mountOpts, ALPINE, "cat"}
for _, f := range tt.containerFiles {
args = append(args, f.file)
}
session := podmanTest.PodmanExitCleanly(args...)
outs := session.OutputToStringArray()
Expect(outs).To(HaveLen(len(tt.containerFiles)))
for i, f := range tt.containerFiles {
Expect(outs[i]).To(Equal(f.content))
}
}
})
It("podman artifact mount remove while in use", func() {
ctrName := "ctr1"
artifactName := "localhost/test"
artifactFileName := "somefile"
artifactFile := filepath.Join(podmanTest.TempDir, artifactFileName)
err := os.WriteFile(artifactFile, []byte("hello world\n"), 0o644)
Expect(err).ToNot(HaveOccurred())
podmanTest.PodmanExitCleanly("artifact", "add", artifactName, artifactFile)
// FIXME: we need https://github.com/containers/container-selinux/pull/360 to fix the selinux access problem, until then disable it.
podmanTest.PodmanExitCleanly("run", "--security-opt=label=disable", "--name", ctrName, "-d", "--mount", "type=artifact,src="+artifactName+",dst=/test", ALPINE, "sleep", "100")
podmanTest.PodmanExitCleanly("artifact", "rm", artifactName)
// file must sill be readable after artifact removal
session := podmanTest.PodmanExitCleanly("exec", ctrName, "cat", "/test/"+artifactFileName)
Expect(session.OutputToString()).To(Equal("hello world"))
// restart will fail if artifact does not exist
session = podmanTest.Podman([]string{"restart", "-t0", ctrName})
session.WaitWithDefaultTimeout()
Expect(session).To(ExitWithError(125, artifactName+": artifact does not exist"))
// create a artifact with the same name again and add another file to ensure it picks up the changes
artifactFile2Name := "otherfile"
artifactFile2 := filepath.Join(podmanTest.TempDir, artifactFile2Name)
err = os.WriteFile(artifactFile2, []byte("second file"), 0o644)
Expect(err).ToNot(HaveOccurred())
podmanTest.PodmanExitCleanly("artifact", "add", artifactName, artifactFile, artifactFile2)
podmanTest.PodmanExitCleanly("start", ctrName)
session = podmanTest.PodmanExitCleanly("exec", ctrName, "cat", "/test/"+artifactFileName, "/test/"+artifactFile2Name)
Expect(session.OutputToString()).To(Equal("hello world second file"))
})
It("podman artifact mount dest conflict", func() {
tests := []struct {
name string
mount string
}{
{
name: "bind mount --volume",
mount: "--volume=/tmp:/test",
},
{
name: "overlay mount",
mount: "--volume=/tmp:/test:O",
},
{
name: "named volume",
mount: "--volume=abc:/test:O",
},
{
name: "bind mount --mount type=bind",
mount: "--mount=type=bind,src=/tmp,dst=/test",
},
{
name: "image mount",
mount: "--mount=type=bind,src=someimage,dst=/test",
},
{
name: "tmpfs mount",
mount: "--tmpfs=/test",
},
{
name: "artifact mount",
mount: "--mount=type=artifact,src=abc,dst=/test",
},
}
for _, tt := range tests {
By(tt.name)
session := podmanTest.Podman([]string{"run", "--rm", "--mount", "type=artifact,src=someartifact,dst=/test", tt.mount, ALPINE})
session.WaitWithDefaultTimeout()
Expect(session).To(ExitWithError(125, "/test: duplicate mount destination"))
}
})
})

View File

@ -475,7 +475,7 @@ var _ = Describe("Podman artifact", func() {
a := podmanTest.InspectArtifact(artifact1Name)
Expect(a.Manifest.Layers).To(HaveLen(1))
Expect(a.TotalSizeBytes()).To(Equal(int64(524288)))
Expect(a.TotalSizeBytes()).To(Equal(int64(1024)))
})
It("podman artifact add file already exists in artifact", func() {
@ -506,7 +506,7 @@ var _ = Describe("Podman artifact", func() {
a := podmanTest.InspectArtifact(artifact1Name)
Expect(a.Manifest.Layers).To(HaveLen(1))
Expect(a.TotalSizeBytes()).To(Equal(int64(1048576)))
Expect(a.TotalSizeBytes()).To(Equal(int64(2048)))
})
It("podman artifact add with --append and --type", func() {

View File

@ -5,6 +5,7 @@ package integration
import (
"bufio"
"bytes"
crand "crypto/rand"
"encoding/json"
"errors"
"fmt"
@ -1390,6 +1391,7 @@ func writeYaml(content string, fileName string) error {
func GetPort() int {
portMin := 5000
portMax := 5999
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
// Avoid dup-allocation races between parallel ginkgo processes
@ -1617,16 +1619,22 @@ func setupRegistry(portOverride *int) (*lockfile.LockFile, string, error) {
}
func createArtifactFile(numBytes int64) (string, error) {
GinkgoHelper()
artifactDir := filepath.Join(podmanTest.TempDir, "artifacts")
if err := os.MkdirAll(artifactDir, 0755); err != nil {
return "", err
}
filename := RandomString(8)
outFile := filepath.Join(artifactDir, filename)
session := podmanTest.Podman([]string{"run", "-v", fmt.Sprintf("%s:/artifacts:z", artifactDir), ALPINE, "dd", "if=/dev/urandom", fmt.Sprintf("of=%s", filepath.Join("/artifacts", filename)), "bs=1b", fmt.Sprintf("count=%d", numBytes)})
session.WaitWithDefaultTimeout()
if session.ExitCode() != 0 {
return "", errors.New("unable to generate artifact file")
f, err := os.Create(filepath.Join(artifactDir, filename))
if err != nil {
return "", err
}
defer f.Close()
_, err = io.CopyN(f, crand.Reader, numBytes)
if err != nil {
return "", err
}
return outFile, nil
}