diff --git a/cmd/cmd.go b/cmd/cmd.go index 837c9c96f4..1763b0107c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -76,6 +76,7 @@ func NewPackCommand(logger ConfigurableLogger) (*cobra.Command, error) { commands.AddHelpFlag(rootCmd, "pack") rootCmd.AddCommand(commands.Build(logger, cfg, packClient)) + rootCmd.AddCommand(commands.Detect(logger, cfg, packClient)) rootCmd.AddCommand(commands.NewBuilderCommand(logger, cfg, packClient)) rootCmd.AddCommand(commands.NewBuildpackCommand(logger, cfg, packClient, buildpackage.NewConfigReader())) rootCmd.AddCommand(commands.NewExtensionCommand(logger, cfg, packClient, buildpackage.NewConfigReader())) diff --git a/internal/build/container_ops_test.go b/internal/build/container_ops_test.go index fb1b9bd423..463c97367f 100644 --- a/internal/build/container_ops_test.go +++ b/internal/build/container_ops_test.go @@ -467,7 +467,7 @@ drwxrwxrwx 2 123 456 (.*) some-vol h.AssertNil(t, err) defer cleanupContainer(ctx, ctr.ID) - writeOp := build.WriteRunToml(containerPath, []builder.RunImageMetadata{builder.RunImageMetadata{ + writeOp := build.WriteRunToml(containerPath, []builder.RunImageMetadata{{ Image: "image-1", Mirrors: []string{ "mirror-1", diff --git a/internal/build/lifecycle_execution.go b/internal/build/lifecycle_execution.go index 0f5c9a49a4..821eb61533 100644 --- a/internal/build/lifecycle_execution.go +++ b/internal/build/lifecycle_execution.go @@ -295,6 +295,18 @@ func (l *LifecycleExecution) Run(ctx context.Context, phaseFactoryCreator PhaseF return l.Create(ctx, buildCache, launchCache, phaseFactory) } +func (l *LifecycleExecution) RunDetect(ctx context.Context, phaseFactoryCreator PhaseFactoryCreator) error { + phaseFactory := phaseFactoryCreator(l) + + l.logger.Info(style.Step("DETECTING")) + if err := l.Detect(ctx, phaseFactory); err != nil { + return err + } + + return nil + // return l.Create(ctx, buildCache, launchCache, phaseFactory) +} + func (l *LifecycleExecution) Cleanup() error { var reterr error if err := l.docker.VolumeRemove(context.Background(), l.layersVolume, true); err != nil { diff --git a/internal/build/lifecycle_executor.go b/internal/build/lifecycle_executor.go index 09d9011f0c..3115d361a8 100644 --- a/internal/build/lifecycle_executor.go +++ b/internal/build/lifecycle_executor.go @@ -129,3 +129,18 @@ func (l *LifecycleExecutor) Execute(ctx context.Context, opts LifecycleOptions) lifecycleExec.Run(ctx, NewDefaultPhaseFactory) }) } + +func (l *LifecycleExecutor) Detect(ctx context.Context, opts LifecycleOptions) error { + tmpDir, err := os.MkdirTemp("", "pack.tmp") + if err != nil { + return err + } + + lifecycleExec, err := NewLifecycleExecution(l.logger, l.docker, tmpDir, opts) + if err != nil { + return err + } + + defer lifecycleExec.Cleanup() + return lifecycleExec.RunDetect(ctx, NewDefaultPhaseFactory) +} diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 11fa645ca5..5352206e85 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -26,6 +26,7 @@ type PackClient interface { PackageBuildpack(ctx context.Context, opts client.PackageBuildpackOptions) error PackageExtension(ctx context.Context, opts client.PackageBuildpackOptions) error Build(context.Context, client.BuildOptions) error + Detect(context.Context, client.BuildOptions) error RegisterBuildpack(context.Context, client.RegisterBuildpackOptions) error YankBuildpack(client.YankBuildpackOptions) error InspectBuildpack(client.InspectBuildpackOptions) (*client.BuildpackInfo, error) diff --git a/internal/commands/detect.go b/internal/commands/detect.go new file mode 100644 index 0000000000..90d5f7d362 --- /dev/null +++ b/internal/commands/detect.go @@ -0,0 +1,128 @@ +package commands + +import ( + "github.com/google/go-containerregistry/pkg/name" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/pkg/client" + "github.com/buildpacks/pack/pkg/image" + "github.com/buildpacks/pack/pkg/logging" +) + +type DetectFlags struct { + AppPath string + Builder string + Registry string + DescriptorPath string + LifecycleImage string + Volumes []string + PreBuildpacks []string + PostBuildpacks []string + Policy string + Network string + Env []string + EnvFiles []string + Buildpacks []string + Extensions []string +} + +// Run Detect phase of lifecycle against a source code +func Detect(logger logging.Logger, cfg config.Config, packClient PackClient) *cobra.Command { + var flags DetectFlags + + cmd := &cobra.Command{ + Use: "detect", + Args: cobra.ExactArgs(0), + Short: "Run the detect phase of buildpacks against your source code", + Example: "pack detect --path apps/test-app --builder cnbs/sample-builder:bionic", + Long: "Pack Detect uses Cloud Native Buildpacks to run the detect phase of buildpack groups against the source code.\n" + + "You can use `--path` to specify a different source code directory, else it defaults to the current directory. Detect requires a `builder`, which can either " + + "be provided directly to build using `--builder`, or can be set using the `set-default-builder` command.", + RunE: logError(logger, func(cmd *cobra.Command, args []string) error { + if err := validateDetectFlags(&flags, cfg, logger); err != nil { + return err + } + + builder := flags.Builder + + if builder == "" { + suggestSettingBuilder(logger, packClient) + return client.NewSoftError() + } + + buildpacks := flags.Buildpacks + extensions := flags.Extensions + + env, err := parseEnv(flags.EnvFiles, flags.Env) + if err != nil { + return err + } + + stringPolicy := flags.Policy + if stringPolicy == "" { + stringPolicy = cfg.PullPolicy + } + pullPolicy, err := image.ParsePullPolicy(stringPolicy) + if err != nil { + return errors.Wrapf(err, "parsing pull policy %s", flags.Policy) + } + var lifecycleImage string + if flags.LifecycleImage != "" { + ref, err := name.ParseReference(flags.LifecycleImage) + if err != nil { + return errors.Wrapf(err, "parsing lifecycle image %s", flags.LifecycleImage) + } + lifecycleImage = ref.Name() + } + + if err := packClient.Detect(cmd.Context(), client.BuildOptions{ + AppPath: flags.AppPath, + Builder: builder, + Env: env, + + PullPolicy: pullPolicy, + ContainerConfig: client.ContainerConfig{ + Network: flags.Network, + Volumes: flags.Volumes, + }, + LifecycleImage: lifecycleImage, + PreBuildpacks: flags.PreBuildpacks, + PostBuildpacks: flags.PostBuildpacks, + + Buildpacks: buildpacks, + Extensions: extensions, + }); err != nil { + return errors.Wrap(err, "failed to detect") + } + return nil + }), + } + detectCommandFlags(cmd, &flags, cfg) + AddHelpFlag(cmd, "detect") + return cmd +} + +// TODO: Incomplete +func validateDetectFlags(flags *DetectFlags, cfg config.Config, logger logging.Logger) error { + // Have to implement + return nil +} + +// TODO: Incomplete +func detectCommandFlags(cmd *cobra.Command, detectFlags *DetectFlags, cfg config.Config) { + cmd.Flags().StringVarP(&detectFlags.AppPath, "path", "p", "", "Path to app dir or zip-formatted file (defaults to current working directory)") + cmd.Flags().StringSliceVarP(&detectFlags.Buildpacks, "buildpack", "b", nil, "Buildpack to use. One of:\n a buildpack by id and version in the form of '@',\n path to a buildpack directory (not supported on Windows),\n path/URL to a buildpack .tar or .tgz file, or\n a packaged buildpack image name in the form of '/[:]'"+stringSliceHelp("buildpack")) + cmd.Flags().StringSliceVarP(&detectFlags.Extensions, "extension", "", nil, "Extension to use. One of:\n an extension by id and version in the form of '@',\n path to an extension directory (not supported on Windows),\n path/URL to an extension .tar or .tgz file, or\n a packaged extension image name in the form of '/[:]'"+stringSliceHelp("extension")) + cmd.Flags().StringVarP(&detectFlags.Builder, "builder", "B", cfg.DefaultBuilder, "Builder image") + cmd.Flags().StringArrayVarP(&detectFlags.Env, "env", "e", []string{}, "Build-time environment variable, in the form 'VAR=VALUE' or 'VAR'.\nWhen using latter value-less form, value will be taken from current\n environment at the time this command is executed.\nThis flag may be specified multiple times and will override\n individual values defined by --env-file."+stringArrayHelp("env")+"\nNOTE: These are NOT available at image runtime.") + cmd.Flags().StringArrayVar(&detectFlags.EnvFiles, "env-file", []string{}, "Build-time environment variables file\nOne variable per line, of the form 'VAR=VALUE' or 'VAR'\nWhen using latter value-less form, value will be taken from current\n environment at the time this command is executed\nNOTE: These are NOT available at image runtime.\"") + cmd.Flags().StringVar(&detectFlags.Network, "network", "", "Connect detect and build containers to network") + cmd.Flags().StringVar(&detectFlags.Policy, "pull-policy", "", `Pull policy to use. Accepted values are always, never, and if-not-present. (default "always")`) + // cmd.Flags().StringVarP(&detectFlags.DescriptorPath, "descriptor", "d", "", "Path to the project descriptor file") + cmd.Flags().StringVar(&detectFlags.LifecycleImage, "lifecycle-image", cfg.LifecycleImage, `Custom lifecycle image to use for analysis, restore, and export when builder is untrusted.`) + cmd.Flags().StringArrayVar(&detectFlags.Volumes, "volume", nil, "Mount host volume into the build container, in the form ':[:]'.\n- 'host path': Name of the volume or absolute directory path to mount.\n- 'target path': The path where the file or directory is available in the container.\n- 'options' (default \"ro\"): An optional comma separated list of mount options.\n - \"ro\", volume contents are read-only.\n - \"rw\", volume contents are readable and writeable.\n - \"volume-opt==\", can be specified more than once, takes a key-value pair consisting of the option name and its value."+stringArrayHelp("volume")) + cmd.Flags().StringArrayVar(&detectFlags.PreBuildpacks, "pre-buildpack", []string{}, "Buildpacks to prepend to the groups in the builder's order") + cmd.Flags().StringArrayVar(&detectFlags.PostBuildpacks, "post-buildpack", []string{}, "Buildpacks to append to the groups in the builder's order") +} diff --git a/internal/commands/testmocks/mock_pack_client.go b/internal/commands/testmocks/mock_pack_client.go index f49b92def9..7bde53ebe9 100644 --- a/internal/commands/testmocks/mock_pack_client.go +++ b/internal/commands/testmocks/mock_pack_client.go @@ -64,6 +64,20 @@ func (mr *MockPackClientMockRecorder) CreateBuilder(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateBuilder", reflect.TypeOf((*MockPackClient)(nil).CreateBuilder), arg0, arg1) } +// Detect mocks base method. +func (m *MockPackClient) Detect(arg0 context.Context, arg1 client.BuildOptions) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Detect", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Detect indicates an expected call of Detect. +func (mr *MockPackClientMockRecorder) Detect(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Detect", reflect.TypeOf((*MockPackClient)(nil).Detect), arg0, arg1) +} + // DownloadSBOM mocks base method. func (m *MockPackClient) DownloadSBOM(arg0 string, arg1 client.DownloadSBOMOptions) error { m.ctrl.T.Helper() diff --git a/internal/fakes/fake_lifecycle.go b/internal/fakes/fake_lifecycle.go index 280c1bf1d8..a431ef69ec 100644 --- a/internal/fakes/fake_lifecycle.go +++ b/internal/fakes/fake_lifecycle.go @@ -14,3 +14,8 @@ func (f *FakeLifecycle) Execute(ctx context.Context, opts build.LifecycleOptions f.Opts = opts return nil } + +func (f *FakeLifecycle) Detect(ctx context.Context, opts build.LifecycleOptions) error { + f.Opts = opts + return nil +} diff --git a/pkg/client/build.go b/pkg/client/build.go index 670f53f2c8..ec872cb861 100644 --- a/pkg/client/build.go +++ b/pkg/client/build.go @@ -72,6 +72,7 @@ type LifecycleExecutor interface { // Execute is responsible for invoking each of these binaries // with the desired configuration. Execute(ctx context.Context, opts build.LifecycleOptions) error + Detect(ctx context.Context, opts build.LifecycleOptions) error } type IsTrustedBuilder func(string) bool diff --git a/pkg/client/detect.go b/pkg/client/detect.go new file mode 100644 index 0000000000..9d5a591c54 --- /dev/null +++ b/pkg/client/detect.go @@ -0,0 +1,172 @@ +package client + +import ( + "context" + "fmt" + "strings" + + "github.com/docker/docker/api/types" + "github.com/pkg/errors" + + "github.com/buildpacks/pack/internal/build" + "github.com/buildpacks/pack/internal/builder" + internalConfig "github.com/buildpacks/pack/internal/config" + "github.com/buildpacks/pack/internal/style" + "github.com/buildpacks/pack/pkg/image" +) + +func (c *Client) Detect(ctx context.Context, opts BuildOptions) error { + appPath, err := c.processAppPath(opts.AppPath) + if err != nil { + return errors.Wrapf(err, "invalid app path '%s'", opts.AppPath) + } + + proxyConfig := c.processProxyConfig(opts.ProxyConfig) + + builderRef, err := c.processBuilderName(opts.Builder) + if err != nil { + return errors.Wrapf(err, "invalid builder '%s'", opts.Builder) + } + + rawBuilderImage, err := c.imageFetcher.Fetch(ctx, builderRef.Name(), image.FetchOptions{Daemon: true, PullPolicy: opts.PullPolicy}) + if err != nil { + return errors.Wrapf(err, "failed to fetch builder image '%s'", builderRef.Name()) + } + + builderOS, err := rawBuilderImage.OS() + if err != nil { + return errors.Wrapf(err, "getting builder OS") + } + + builderArch, err := rawBuilderImage.Architecture() + if err != nil { + return errors.Wrapf(err, "getting builder architecture") + } + + bldr, err := c.getBuilder(rawBuilderImage) + if err != nil { + return errors.Wrapf(err, "invalid builder %s", style.Symbol(opts.Builder)) + } + + fetchedBPs, order, err := c.processBuildpacks(ctx, bldr.Image(), bldr.Buildpacks(), bldr.Order(), bldr.StackID, opts) + if err != nil { + return err + } + + fetchedExs, orderExtensions, err := c.processExtensions(ctx, bldr.Image(), bldr.Extensions(), bldr.OrderExtensions(), bldr.StackID, opts) + if err != nil { + return err + } + + // Ensure the builder's platform APIs are supported + var builderPlatformAPIs builder.APISet + builderPlatformAPIs = append(builderPlatformAPIs, bldr.LifecycleDescriptor().APIs.Platform.Deprecated...) + builderPlatformAPIs = append(builderPlatformAPIs, bldr.LifecycleDescriptor().APIs.Platform.Supported...) + if !supportsPlatformAPI(builderPlatformAPIs) { + c.logger.Debugf("pack %s supports Platform API(s): %s", c.version, strings.Join(build.SupportedPlatformAPIVersions.AsStrings(), ", ")) + c.logger.Debugf("Builder %s supports Platform API(s): %s", style.Symbol(opts.Builder), strings.Join(builderPlatformAPIs.AsStrings(), ", ")) + return errors.Errorf("Builder %s is incompatible with this version of pack", style.Symbol(opts.Builder)) + } + + // Get the platform API version to use + lifecycleVersion := bldr.LifecycleDescriptor().Info.Version + var ( + lifecycleOptsLifecycleImage string + lifecycleAPIs []string + ) + + if supportsLifecycleImage(lifecycleVersion) { + lifecycleImageName := opts.LifecycleImage + if lifecycleImageName == "" { + lifecycleImageName = fmt.Sprintf("%s:%s", internalConfig.DefaultLifecycleImageRepo, lifecycleVersion.String()) + } + + lifecycleImage, err := c.imageFetcher.Fetch( + ctx, + lifecycleImageName, + image.FetchOptions{ + Daemon: true, + PullPolicy: opts.PullPolicy, + Platform: fmt.Sprintf("%s/%s", builderOS, builderArch), + }, + ) + if err != nil { + return fmt.Errorf("fetching lifecycle image: %w", err) + } + + lifecycleOptsLifecycleImage = lifecycleImage.Name() + labels, err := lifecycleImage.Labels() + if err != nil { + return fmt.Errorf("reading labels of lifecycle image: %w", err) + } + + lifecycleAPIs, err = extractSupportedLifecycleApis(labels) + if err != nil { + return fmt.Errorf("reading api versions of lifecycle image: %w", err) + } + } + + usingPlatformAPI, err := build.FindLatestSupported(append( + bldr.LifecycleDescriptor().APIs.Platform.Deprecated, + bldr.LifecycleDescriptor().APIs.Platform.Supported...), + lifecycleAPIs) + if err != nil { + return fmt.Errorf("finding latest supported Platform API: %w", err) + } + + buildEnvs := map[string]string{} + + for k, v := range opts.Env { + buildEnvs[k] = v + } + + ephemeralBuilder, err := c.createEphemeralBuilder(rawBuilderImage, buildEnvs, order, fetchedBPs, orderExtensions, fetchedExs, usingPlatformAPI.LessThan("0.12")) + if err != nil { + return err + } + defer c.docker.ImageRemove(context.Background(), ephemeralBuilder.Name(), types.ImageRemoveOptions{Force: true}) + + if len(bldr.OrderExtensions()) > 0 || len(ephemeralBuilder.OrderExtensions()) > 0 { + if !c.experimental { + return fmt.Errorf("experimental features must be enabled when builder contains image extensions") + } + if builderOS == "windows" { + return fmt.Errorf("builder contains image extensions which are not supported for Windows builds") + } + if !(opts.PullPolicy == image.PullAlways) { + return fmt.Errorf("pull policy must be 'always' when builder contains image extensions") + } + } + + processedVolumes, warnings, err := processVolumes(builderOS, opts.ContainerConfig.Volumes) + if err != nil { + return err + } + for _, warning := range warnings { + c.logger.Warn(warning) + } + + lifecycleOpts := build.LifecycleOptions{ + AppPath: appPath, + Builder: ephemeralBuilder, + BuilderImage: builderRef.Name(), + LifecycleImage: ephemeralBuilder.Name(), + HTTPProxy: proxyConfig.HTTPProxy, + HTTPSProxy: proxyConfig.HTTPSProxy, + NoProxy: proxyConfig.NoProxy, + Network: opts.ContainerConfig.Network, + Volumes: processedVolumes, + Keychain: c.keychain, + } + + if supportsLifecycleImage(lifecycleVersion) { + lifecycleOpts.LifecycleImage = lifecycleOptsLifecycleImage + lifecycleOpts.LifecycleApis = lifecycleAPIs + } + + if err = c.lifecycleExecutor.Detect(ctx, lifecycleOpts); err != nil { + return fmt.Errorf("executing detect: %w", err) + } + // Log the final detected group + return nil +} diff --git a/pkg/testmocks/mock_access_checker.go b/pkg/testmocks/mock_access_checker.go new file mode 100644 index 0000000000..558b85a580 --- /dev/null +++ b/pkg/testmocks/mock_access_checker.go @@ -0,0 +1,48 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/buildpacks/pack/pkg/client (interfaces: AccessChecker) + +// Package testmocks is a generated GoMock package. +package testmocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockAccessChecker is a mock of AccessChecker interface. +type MockAccessChecker struct { + ctrl *gomock.Controller + recorder *MockAccessCheckerMockRecorder +} + +// MockAccessCheckerMockRecorder is the mock recorder for MockAccessChecker. +type MockAccessCheckerMockRecorder struct { + mock *MockAccessChecker +} + +// NewMockAccessChecker creates a new mock instance. +func NewMockAccessChecker(ctrl *gomock.Controller) *MockAccessChecker { + mock := &MockAccessChecker{ctrl: ctrl} + mock.recorder = &MockAccessCheckerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockAccessChecker) EXPECT() *MockAccessCheckerMockRecorder { + return m.recorder +} + +// Check mocks base method. +func (m *MockAccessChecker) Check(arg0 string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Check", arg0) + ret0, _ := ret[0].(bool) + return ret0 +} + +// Check indicates an expected call of Check. +func (mr *MockAccessCheckerMockRecorder) Check(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Check", reflect.TypeOf((*MockAccessChecker)(nil).Check), arg0) +}