Skip to content

feat: push --layer-format tar #2982

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 1 commit into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
- `allow uts ns` : invalidate the use of the `--uts` and `--hostname` flags.
- A new `singularity data package` command allows files and directories to
be packaged into an OCI-SIF data container.
- A new `--layer-format` flag for `singularity push` allows layers in an OCI-SIF
image to be pushed to `library://` and `docker://` registries in `squashfs`
(default) or `tar` format. Images pushed with `--layer-format tar` can be
pulled and run by other OCI runtimes.

### Bug Fixes

Expand Down
24 changes: 23 additions & 1 deletion cmd/internal/cli/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ var (

// pushDescription holds a description to be set against a library container
pushDescription string

// pushLayerFormat sets the layer format to be used when pushing OCI images.
pushLayerFormat string
)

// --library
Expand Down Expand Up @@ -65,6 +68,16 @@ var pushDescriptionFlag = cmdline.Flag{
Usage: "description for container image (library:// only)",
}

// --layer-format
var pushLayerFormatFlag = cmdline.Flag{
ID: "pushLayerFormatFlag",
Value: &pushLayerFormat,
DefaultValue: "",
Name: "layer-format",
Usage: "layer format when pushing OCI-SIF images - squashfs (default) or tar",
EnvKeys: []string{"LAYER_FORMAT"},
}

func init() {
addCmdInit(func(cmdManager *cmdline.CommandManager) {
cmdManager.RegisterCmd(PushCmd)
Expand All @@ -78,6 +91,9 @@ func init() {
cmdManager.RegisterFlagForCmd(&dockerPasswordFlag, PushCmd)

cmdManager.RegisterFlagForCmd(&commonAuthFileFlag, PushCmd)
cmdManager.RegisterFlagForCmd(&commonTmpDirFlag, PushCmd)

cmdManager.RegisterFlagForCmd(&pushLayerFormatFlag, PushCmd)
})
}

Expand Down Expand Up @@ -132,6 +148,7 @@ var PushCmd = &cobra.Command{
Description: pushDescription,
Endpoint: currentRemoteEndpoint,
LibraryConfig: lc,
LayerFormat: pushLayerFormat,
}

resp, err := library.Push(cmd.Context(), file, destRef, pushCfg)
Expand Down Expand Up @@ -184,7 +201,12 @@ var PushCmd = &cobra.Command{
if err != nil {
sylog.Fatalf("Unable to make docker oci credentials: %s", err)
}
if err := oci.Push(cmd.Context(), file, ref, ociAuth, reqAuthFile); err != nil {
opts := oci.PushOptions{
Auth: ociAuth,
AuthFile: reqAuthFile,
LayerFormat: pushLayerFormat,
}
if err := oci.Push(cmd.Context(), file, ref, opts); err != nil {
sylog.Fatalf("Unable to push image to oci registry: %v", err)
}
sylog.Infof("Upload complete")
Expand Down
44 changes: 44 additions & 0 deletions e2e/push/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ package push
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"

"github.com/pkg/errors"
"github.com/sylabs/singularity/v4/e2e/internal/e2e"
"github.com/sylabs/singularity/v4/e2e/internal/testhelper"
"github.com/sylabs/singularity/v4/internal/pkg/test/tool/require"
)

type ctx struct {
Expand Down Expand Up @@ -165,6 +167,47 @@ func (c ctx) testPushCmd(t *testing.T) {
}
}

func (c ctx) testPushOCITarLayers(t *testing.T) {
e2e.EnsureOCISIF(t, c.env)

imgRef := fmt.Sprintf("docker://%s/docker_oci-tar-layers:test", c.env.TestRegistry)

// Push OCI-SIF to registry with tar layer format.
args := []string{
"--layer-format",
"tar",
c.env.OCISIFPath,
imgRef,
}
c.env.RunSingularity(
t,
e2e.AsSubtest("push"),
e2e.WithProfile(e2e.UserProfile),
e2e.WithCommand("push"),
e2e.WithArgs(args...),
e2e.ExpectExit(0),
)

// If docker is available, ensure image that was pushed can be run with docker.
t.Run("docker", func(t *testing.T) {
require.Command(t, "docker")
// Temporary homedir for docker commands, so invoking docker doesn't create
// a ~/.docker that may interfere elsewhere.
tmpHome, cleanupHome := e2e.MakeTempDir(t, c.env.TestDir, "docker-", "")
t.Cleanup(func() { e2e.Privileged(cleanupHome)(t) })
// Privileged so we can access docker socket.
e2e.Privileged(func(t *testing.T) {
dockerRef := strings.TrimPrefix(imgRef, "docker://")
cmd := exec.Command("docker", "run", "-i", "--rm", dockerRef, "/bin/true")
cmd.Env = append(cmd.Env, "HOME="+tmpHome)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Unexpected error while running command.\n%s: %s", err, string(out))
}
})(t)
})
}

// E2ETests is the main func to trigger the test suite
func E2ETests(env e2e.TestEnv) testhelper.Tests {
c := ctx{
Expand All @@ -174,5 +217,6 @@ func E2ETests(env e2e.TestEnv) testhelper.Tests {
return testhelper.Tests{
"invalid transport": c.testInvalidTransport,
"oras": c.testPushCmd,
"oci tar layers": c.testPushOCITarLayers,
}
}
14 changes: 12 additions & 2 deletions internal/pkg/client/library/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ type PushOptions struct {
// LibraryConfig configures operations against the library using its native
// API, via sylabs/scs-library-client.
LibraryConfig *scslibrary.Config
// LayerFormat sets the layer format to use when pushing OCI(-SIF) images only.
LayerFormat string
// TmpDir is a temporary directory to be used for an temporary files created
// during the push.
TmpDir string
}

// Push will upload an image file to the library.
Expand All @@ -49,7 +54,6 @@ func Push(ctx context.Context, sourceFile string, destRef *scslibrary.Ref, opts
if _, err := f.GetDescriptor(sif.WithDataType(sif.DataOCIRootIndex)); err == nil {
return nil, pushOCI(ctx, sourceFile, destRef, opts)
}

return pushNative(ctx, sourceFile, destRef, opts)
}

Expand Down Expand Up @@ -127,5 +131,11 @@ func pushOCI(ctx context.Context, sourceFile string, destRef *scslibrary.Ref, op
}

sylog.Debugf("Pushing to OCI registry at: %s", pushRef)
return ocisif.PushOCISIF(ctx, sourceFile, pushRef, lr.authConfig(), "")
ocisifOpts := ocisif.PushOptions{
Auth: lr.authConfig(),
AuthFile: "",
LayerFormat: opts.LayerFormat,
TmpDir: opts.TmpDir,
}
return ocisif.PushOCISIF(ctx, sourceFile, pushRef, ocisifOpts)
}
26 changes: 24 additions & 2 deletions internal/pkg/client/oci/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,25 @@ import (
"github.com/sylabs/singularity/v4/pkg/image"
)

// PushOptions provides options/configuration that determine the behavior of a
// push to an OCI registry.
type PushOptions struct {
// Auth provides optional explicit credentials for OCI registry authentication.
Auth *authn.AuthConfig
// AuthFile provides a path to a file containing OCI registry credentials.
AuthFile string
// LayerFormat sets an explicit layer format to use when pushing an OCI
// image. Either 'squashfs' or 'tar'. If unset, layers are pushed as
// squashfs.
LayerFormat string
// TmpDir is a temporary directory to be used for an temporary files created
// during the push.
TmpDir string
}

// Push pushes an image into an OCI registry, as an OCI image (not an ORAS artifact).
// At present, only OCI-SIF images can be pushed in this manner.
func Push(ctx context.Context, sourceFile string, destRef string, ociAuth *authn.AuthConfig, reqAuthFile string) error {
func Push(ctx context.Context, sourceFile string, destRef string, opts PushOptions) error {
img, err := image.Init(sourceFile, false)
if err != nil {
return err
Expand All @@ -25,7 +41,13 @@ func Push(ctx context.Context, sourceFile string, destRef string, ociAuth *authn

switch img.Type {
case image.OCISIF:
return ocisif.PushOCISIF(ctx, sourceFile, destRef, ociAuth, reqAuthFile)
ocisifOpts := ocisif.PushOptions{
Auth: opts.Auth,
AuthFile: opts.AuthFile,
LayerFormat: opts.LayerFormat,
TmpDir: opts.TmpDir,
}
return ocisif.PushOCISIF(ctx, sourceFile, destRef, ocisifOpts)
case image.SIF:
return fmt.Errorf("non OCI SIF images can only be pushed to OCI registries via oras://")
}
Expand Down
73 changes: 71 additions & 2 deletions internal/pkg/client/ocisif/ocisif.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import (
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
ggcrv1 "github.com/google/go-containerregistry/pkg/v1"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/google/go-containerregistry/pkg/v1/tarball"
ocimutate "github.com/sylabs/oci-tools/pkg/mutate"
ocitsif "github.com/sylabs/oci-tools/pkg/sif"
"github.com/sylabs/sif/v2/pkg/sif"
"github.com/sylabs/singularity/v4/internal/pkg/cache"
Expand Down Expand Up @@ -135,8 +138,30 @@ func createOciSif(ctx context.Context, tOpts *ociimage.TransportOptions, imgCach
return w.Write()
}

const (
defaultLayerFormat = ""
squashfsLayerFormat = "squashfs"
tarLayerFormat = "tar"
)

// PushOptions provides options/configuration that determine the behavior of a
// push to an OCI registry.
type PushOptions struct {
// Auth provides optional explicit credentials for OCI registry authentication.
Auth *authn.AuthConfig
// AuthFile provides a path to a file containing OCI registry credentials.
AuthFile string
// LayerFormat sets an explicit layer format to use when pushing an OCI
// image. Either 'squashfs' or 'tar'. If unset, layers are pushed as
// squashfs.
LayerFormat string
// TmpDir is a temporary directory to be used for an temporary files created
// during the push.
TmpDir string
}

// PushOCISIF pushes a single image from sourceFile to the OCI registry destRef.
func PushOCISIF(ctx context.Context, sourceFile, destRef string, ociAuth *authn.AuthConfig, reqAuthFile string) error {
func PushOCISIF(ctx context.Context, sourceFile, destRef string, opts PushOptions) error {
destRef = strings.TrimPrefix(destRef, "docker://")
destRef = strings.TrimPrefix(destRef, "//")
ir, err := name.ParseReference(destRef)
Expand Down Expand Up @@ -168,8 +193,13 @@ func PushOCISIF(ctx context.Context, sourceFile, destRef string, ociAuth *authn.
return fmt.Errorf("while obtaining image: %w", err)
}

image, err = transformLayers(image, opts.LayerFormat, opts.TmpDir)
if err != nil {
return err
}

remoteOpts := []remote.Option{
ociauth.AuthOptn(ociAuth, reqAuthFile),
ociauth.AuthOptn(opts.Auth, opts.AuthFile),
remote.WithUserAgent(useragent.Value()),
remote.WithContext(ctx),
}
Expand Down Expand Up @@ -204,3 +234,42 @@ func PushOCISIF(ctx context.Context, sourceFile, destRef string, ociAuth *authn.

return remote.Write(ir, image, remoteOpts...)
}

func transformLayers(base v1.Image, layerFormat, tmpDir string) (v1.Image, error) {
ls, err := base.Layers()
if err != nil {
return nil, err
}

ms := []ocimutate.Mutation{}

for i, l := range ls {
// We always expect an OCI-SIF to contain squashfs layers, at present.
mt, err := l.MediaType()
if err != nil {
return nil, err
}
if mt != ocisif.SquashfsLayerMediaType {
return nil, fmt.Errorf("unexpected layer mediaType: %v", mt)
}

switch layerFormat {
case defaultLayerFormat, squashfsLayerFormat:
continue
case tarLayerFormat:
opener, err := ocimutate.TarFromSquashfsLayer(l, ocimutate.OptTarTempDir(tmpDir))
if err != nil {
return nil, err
}
tarLayer, err := tarball.LayerFromOpener(opener)
if err != nil {
return nil, err
}
ms = append(ms, ocimutate.SetLayer(i, tarLayer))
default:
return nil, fmt.Errorf("unsupported layer format: %v", layerFormat)
}
}

return ocimutate.Apply(base, ms...)
}
2 changes: 1 addition & 1 deletion internal/pkg/ocisif/imagewriter.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@ func (w *ImageWriter) Write() error {
}

if w.squashFSLayers {
sylog.Infof("Converting layers to SquashFS")
img, err = imgLayersToSquashfs(img, w.srcDigest, w.workDir)
if err != nil {
return fmt.Errorf("while converting layers: %w", err)
Expand Down Expand Up @@ -158,6 +157,7 @@ func imgLayersToSquashfs(img ggcrv1.Image, digest ggcrv1.Hash, workDir string) (
return img, err
}

sylog.Infof("Converting layers to SquashFS")
var sqOpts []mutate.SquashfsConverterOpt
if len(layers) == 1 {
sqOpts = []mutate.SquashfsConverterOpt{
Expand Down