diff --git a/internal/build/lifecycle_execution.go b/internal/build/lifecycle_execution.go index d55289e73c..055b84b15f 100644 --- a/internal/build/lifecycle_execution.go +++ b/internal/build/lifecycle_execution.go @@ -494,7 +494,14 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach flagsOp := WithFlags(flags...) var analyze RunnerCleaner - if l.opts.Publish { + + layoutOpts := NullOp() + if l.opts.LayoutPath != "" { + args = append([]string{"-layout"}, args...) + layoutOpts = WithBinds(fmt.Sprintf("%s:%s", l.opts.LayoutPath, l.mountPaths.LayoutDir())) + } + + if l.opts.Publish || l.opts.LayoutPath != "" { authConfig, err := auth.BuildEnvVar(authn.DefaultKeychain, l.opts.Image.String(), l.opts.RunImage, l.opts.CacheImage, l.opts.PreviousImage) if err != nil { return err @@ -505,7 +512,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach l, WithLogPrefix("analyzer"), WithImage(l.opts.LifecycleImage), - WithEnv(fmt.Sprintf("%s=%d", builder.EnvUID, l.opts.Builder.UID()), fmt.Sprintf("%s=%d", builder.EnvGID, l.opts.Builder.GID())), + WithEnv(fmt.Sprintf("%s=%d", builder.EnvUID, l.opts.Builder.UID()), fmt.Sprintf("%s=%d", builder.EnvGID, l.opts.Builder.GID()), "CNB_EXPERIMENTAL_MODE=warn"), WithRegistryAccess(authConfig), WithRoot(), WithArgs(l.withLogLevel(args...)...), @@ -513,6 +520,7 @@ func (l *LifecycleExecution) Analyze(ctx context.Context, buildCache, launchCach flagsOp, cacheBindOp, stackOp, + layoutOpts, ) analyze = phaseFactory.New(configProvider) @@ -672,12 +680,21 @@ func (l *LifecycleExecution) Export(ctx context.Context, buildCache, launchCache ) export = phaseFactory.New(NewPhaseConfigProvider("exporter", l, opts...)) } else { - opts = append( - opts, - WithDaemonAccess(l.opts.DockerHost), - WithFlags("-daemon", "-launch-cache", l.mountPaths.launchCacheDir()), - WithBinds(fmt.Sprintf("%s:%s", launchCache.Name(), l.mountPaths.launchCacheDir())), - ) + if l.opts.LayoutPath != "" { + opts = append( + opts, + WithFlags("-layout"), + WithBinds(fmt.Sprintf("%s:%s", l.opts.LayoutPath, l.mountPaths.LayoutDir())), + WithEnv("CNB_EXPERIMENTAL_MODE=warn"), + ) + } else { + opts = append( + opts, + WithDaemonAccess(l.opts.DockerHost), + WithFlags("-daemon", "-launch-cache", l.mountPaths.launchCacheDir()), + WithBinds(fmt.Sprintf("%s:%s", launchCache.Name(), l.mountPaths.launchCacheDir())), + ) + } export = phaseFactory.New(NewPhaseConfigProvider("exporter", l, opts...)) } diff --git a/internal/build/lifecycle_executor.go b/internal/build/lifecycle_executor.go index 0a87defe4b..6cf7647a66 100644 --- a/internal/build/lifecycle_executor.go +++ b/internal/build/lifecycle_executor.go @@ -30,6 +30,8 @@ var ( api.MustParse("0.8"), api.MustParse("0.9"), api.MustParse("0.10"), + api.MustParse("0.11"), + api.MustParse("0.12"), } ) @@ -85,6 +87,7 @@ type LifecycleOptions struct { CacheImage string HTTPProxy string HTTPSProxy string + LayoutPath string NoProxy string Network string AdditionalTags []string diff --git a/internal/build/mount_paths.go b/internal/build/mount_paths.go index e79995e970..9fbd962451 100644 --- a/internal/build/mount_paths.go +++ b/internal/build/mount_paths.go @@ -65,3 +65,7 @@ func (m mountPaths) launchCacheDir() string { func (m mountPaths) sbomDir() string { return m.join(m.volume, "layers", "sbom") } + +func (m mountPaths) LayoutDir() string { + return m.join(m.volume, "oci") +} diff --git a/internal/commands/build.go b/internal/commands/build.go index 860a632e1b..d100a8da70 100644 --- a/internal/commands/build.go +++ b/internal/commands/build.go @@ -65,12 +65,15 @@ func Build(logger logging.Logger, cfg config.Config, packClient PackClient) *cob "be provided directly to build using `--builder`, or can be set using the `set-default-builder` command. For more " + "on how to use `pack build`, see: https://buildpacks.io/docs/app-developer-guide/build-an-app/.", RunE: logError(logger, func(cmd *cobra.Command, args []string) error { - if err := validateBuildFlags(&flags, cfg, packClient, logger); err != nil { + imageName := args[0] + layoutPath := layoutFeature(imageName) + if layoutPath != "" { + imageName = layoutPath + } + if err := validateBuildFlags(&flags, cfg, packClient, layoutPath, logger); err != nil { return err } - imageName := args[0] - descriptor, actualDescriptorPath, err := parseProjectToml(flags.AppPath, flags.DescriptorPath) if err != nil { return err @@ -172,6 +175,7 @@ func Build(logger logging.Logger, cfg config.Config, packClient PackClient) *cob Interactive: flags.Interactive, SBOMDestinationDir: flags.SBOMDestinationDir, CreationTime: dateTime, + LayoutAppPath: layoutPath, }); err != nil { return errors.Wrap(err, "failed to build") } @@ -244,7 +248,7 @@ This option may set DOCKER_HOST environment variable for the build container if } } -func validateBuildFlags(flags *BuildFlags, cfg config.Config, packClient PackClient, logger logging.Logger) error { +func validateBuildFlags(flags *BuildFlags, cfg config.Config, packClient PackClient, layoutPath string, logger logging.Logger) error { if flags.Registry != "" && !cfg.Experimental { return client.NewExperimentError("Support for buildpack registries is currently experimental.") } @@ -273,6 +277,10 @@ func validateBuildFlags(flags *BuildFlags, cfg config.Config, packClient PackCli return client.NewExperimentError("Interactive mode is currently experimental.") } + if layoutPath != "" && !cfg.Experimental { + return client.NewExperimentError("Exporting to OCI layout is currently experimental.") + } + return nil } @@ -339,3 +347,11 @@ func parseProjectToml(appPath, descriptorPath string) (projectTypes.Descriptor, descriptor, err := project.ReadProjectDescriptor(actualPath) return descriptor, actualPath, err } + +func layoutFeature(imageName string) string { + if strings.HasPrefix(imageName, "oci:") { + imageNameParsed := strings.Split(imageName, ":") + return imageNameParsed[1] + } + return "" +} diff --git a/pkg/client/build.go b/pkg/client/build.go index 72eef0e324..9896803943 100644 --- a/pkg/client/build.go +++ b/pkg/client/build.go @@ -89,6 +89,10 @@ type BuildOptions struct { // built atop. RunImage string + // LayoutAppPath is the path to export the output image in OCI layout format + // If unset it defaults to "oci" folder in current working directory. + LayoutAppPath string + // Address of docker daemon exposed to build container // e.g. tcp://example.com:1234, unix:///run/user/1000/podman/podman.sock DockerHost string @@ -222,10 +226,21 @@ var IsSuggestedBuilderFunc = func(b string) bool { // If any configuration is deemed invalid, or if any lifecycle phases fail, // an error will be returned and no image produced. func (c *Client) Build(ctx context.Context, opts BuildOptions) error { + var layoutRootPath string imageRef, err := c.parseTagReference(opts.Image) if err != nil { return errors.Wrapf(err, "invalid image name '%s'", opts.Image) } + imgRegistry := imageRef.Context().RegistryStr() + imageName := imageRef.Name() + + if opts.LayoutAppPath != "" { + layoutRootPath, imageName, err = c.processLayoutPath(opts.LayoutAppPath) + if err != nil { + return errors.Wrapf(err, "invalid path '%s' to save the image in oci layout format", imageName) + } + imgRegistry = "" + } appPath, err := c.processAppPath(opts.AppPath) if err != nil { @@ -249,8 +264,21 @@ func (c *Client) Build(ctx context.Context, opts BuildOptions) error { return errors.Wrapf(err, "invalid builder %s", style.Symbol(opts.Builder)) } - runImageName := c.resolveRunImage(opts.RunImage, imageRef.Context().RegistryStr(), builderRef.Context().RegistryStr(), bldr.Stack(), opts.AdditionalMirrors, opts.Publish) - runImage, err := c.validateRunImage(ctx, runImageName, opts.PullPolicy, opts.Publish, bldr.StackID) + runImageName := c.resolveRunImage(opts.RunImage, imgRegistry, builderRef.Context().RegistryStr(), bldr.Stack(), opts.AdditionalMirrors, opts.Publish) + + fetchOptions := image.FetchOptions{Daemon: !opts.Publish, PullPolicy: opts.PullPolicy} + if layoutRootPath != "" { + reference, err := name.ParseReference(runImageName) + if err != nil { + return err + } + runImagePath := filepath.Join(layoutRootPath, reference.Context().RegistryStr(), reference.Context().RepositoryStr(), reference.Identifier()) + fetchOptions.LayoutOption = image.LayoutOption{ + Path: runImagePath, + Sparse: false, + } + } + runImage, err := c.validateRunImage(ctx, runImageName, fetchOptions, bldr.StackID) if err != nil { return errors.Wrapf(err, "invalid run-image '%s'", runImageName) } @@ -377,9 +405,10 @@ func (c *Client) Build(ctx context.Context, opts BuildOptions) error { GID: opts.GroupID, PreviousImage: opts.PreviousImage, Interactive: opts.Interactive, - Termui: termui.NewTermui(imageRef.Name(), ephemeralBuilder, runImageName), + Termui: termui.NewTermui(imageName, ephemeralBuilder, runImageName), SBOMDestinationDir: opts.SBOMDestinationDir, CreationTime: opts.CreationTime, + LayoutPath: layoutRootPath, } lifecycleVersion := ephemeralBuilder.LifecycleDescriptor().Info.Version @@ -388,7 +417,7 @@ func (c *Client) Build(ctx context.Context, opts BuildOptions) error { lifecycleSupportsCreator := !lifecycleVersion.LessThan(semver.MustParse(minLifecycleVersionSupportingCreator)) if lifecycleSupportsCreator && opts.TrustBuilder(opts.Builder) { - lifecycleOpts.UseCreator = true + lifecycleOpts.UseCreator = false // no need to fetch a lifecycle image, it won't be used if err := c.lifecycleExecutor.Execute(ctx, lifecycleOpts); err != nil { return errors.Wrap(err, "executing lifecycle") @@ -495,11 +524,11 @@ func (c *Client) getBuilder(img imgutil.Image) (*builder.Builder, error) { return bldr, nil } -func (c *Client) validateRunImage(context context.Context, name string, pullPolicy image.PullPolicy, publish bool, expectedStack string) (imgutil.Image, error) { - if name == "" { +func (c *Client) validateRunImage(context context.Context, imageName string, opts image.FetchOptions, expectedStack string) (imgutil.Image, error) { + if imageName == "" { return nil, errors.New("run image must be specified") } - img, err := c.imageFetcher.Fetch(context, name, image.FetchOptions{Daemon: !publish, PullPolicy: pullPolicy}) + img, err := c.imageFetcher.Fetch(context, imageName, opts) if err != nil { return nil, err } @@ -635,6 +664,38 @@ func (c *Client) processAppPath(appPath string) (string, error) { return resolvedAppPath, nil } +func (c *Client) processLayoutPath(imagePath string) (string, string, error) { + var ( + fullImagePath string + err error + ) + if imagePath == "" { + return "", "", nil + } + + /* if fullImagePath, err = filepath.EvalSymlinks(imagePath); err != nil { + return "", "", errors.Wrap(err, "evaluate symlink") + } + */ + + // Abs calls Clean on the result, so at this point the fullImagePath do not have trailing "/" + if fullImagePath, err = filepath.Abs(imagePath); err != nil { + return "", "", errors.Wrap(err, "resolve absolute path") + } + + imageDirectoryName := filepath.Base(fullImagePath) + c.logger.Debugf("imageDirectoryName: %s", imageDirectoryName) + layoutRootPath := filepath.Join(filepath.Dir(fullImagePath), "oci") + c.logger.Debugf("layout root path: %s", layoutRootPath) + fullImagePath = filepath.Join(layoutRootPath, imageDirectoryName) + + if err = os.MkdirAll(fullImagePath, os.ModePerm); err != nil { + return "", "", errors.Wrapf(err, "could not create the directory %s", fullImagePath) + } + c.logger.Debugf("Path to save the image: %s", fullImagePath) + return layoutRootPath, fullImagePath, nil +} + func (c *Client) processProxyConfig(config *ProxyConfig) ProxyConfig { var ( httpProxy, httpsProxy, noProxy string diff --git a/pkg/client/common.go b/pkg/client/common.go index b6e0e8ec29..12fcb93dbe 100644 --- a/pkg/client/common.go +++ b/pkg/client/common.go @@ -3,7 +3,6 @@ package client import ( "errors" "fmt" - "github.com/google/go-containerregistry/pkg/name" "github.com/buildpacks/pack/internal/builder" diff --git a/pkg/image/fetcher.go b/pkg/image/fetcher.go index 4ce9f2e81a..9f4616bdc2 100644 --- a/pkg/image/fetcher.go +++ b/pkg/image/fetcher.go @@ -4,6 +4,8 @@ import ( "context" "encoding/base64" "encoding/json" + "github.com/buildpacks/imgutil/layout" + "github.com/buildpacks/imgutil/layout/sparse" "io" "strings" @@ -27,6 +29,11 @@ import ( // Values in these functions are set through currying. type FetcherOption func(c *Fetcher) +type LayoutOption struct { + Path string + Sparse bool +} + // WithRegistryMirrors supply your own mirrors for registry. func WithRegistryMirrors(registryMirrors map[string]string) FetcherOption { return func(c *Fetcher) { @@ -48,9 +55,10 @@ type Fetcher struct { } type FetchOptions struct { - Daemon bool - Platform string - PullPolicy PullPolicy + Daemon bool + Platform string + PullPolicy PullPolicy + LayoutOption LayoutOption } func NewFetcher(logger logging.Logger, docker client.CommonAPIClient, opts ...FetcherOption) *Fetcher { @@ -75,6 +83,10 @@ func (f *Fetcher) Fetch(ctx context.Context, name string, options FetchOptions) return nil, err } + if (options.LayoutOption != LayoutOption{}) { + return f.fetchLayoutImage(name, options.LayoutOption) + } + if !options.Daemon { return f.fetchRemoteImage(name) } @@ -125,6 +137,35 @@ func (f *Fetcher) fetchRemoteImage(name string) (imgutil.Image, error) { return image, nil } +func (f *Fetcher) fetchLayoutImage(name string, options LayoutOption) (imgutil.Image, error) { + var ( + image imgutil.Image + err error + ) + + v1Image, err := remote.NewV1Image(name, f.keychain) + if err != nil { + return nil, err + } + + if options.Sparse { + image, err = sparse.NewImage(options.Path, v1Image) + } else { + image, err = layout.NewImage(options.Path, layout.FromBaseImage(v1Image)) + } + + if err != nil { + return nil, err + } + + err = image.Save() + if err != nil { + return nil, err + } + + return image, nil +} + func (f *Fetcher) pullImage(ctx context.Context, imageID string, platform string) error { regAuth, err := f.registryAuth(imageID) if err != nil {