Skip to content

add artifact mount support #25397

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 13, 2025
27 changes: 24 additions & 3 deletions docs/source/markdown/options/mount.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
However if the *dst* path is a existing file in the container then the blob will be
However, if the *dst* path is an existing file in the container, then the blob will be

nits, if you have other updates.

mounted directly on it. This only works when the artifact contains of a single blob
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mounted directly on it. This only works when the artifact contains of a single blob
mounted directly on it. This only works when the artifact contains a single blob

nits, if you have other changes.

or when either *digest* or *title* are specified.

Options specific to type=**volume**:

- *ro*, *readonly*: *true* or *false* (default if unspecified: *false*).
Expand Down Expand Up @@ -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`
22 changes: 22 additions & 0 deletions libpod/container.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions libpod/container_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions libpod/container_internal_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions libpod/container_internal_freebsd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
16 changes: 16 additions & 0 deletions libpod/container_internal_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
13 changes: 13 additions & 0 deletions libpod/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions libpod/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 12 additions & 16 deletions pkg/domain/infra/abi/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down
Loading