diff --git a/packager/README.md b/packager/README.md index cb626df5..d4533c04 100644 --- a/packager/README.md +++ b/packager/README.md @@ -20,3 +20,142 @@ For more on go-bindata: https://github.com/jteeuwen/go-bindata ``` ginkgo -r ``` + +--- + +## Selective Dependency Packaging + +`buildpack-packager` supports building **cached** buildpacks that contain only a named +subset of dependencies. This is useful for operators who need a smaller artifact or a +pre-defined variant (e.g. a "minimal" build without optional agent libraries). + +> **Note:** `--profile`, `--exclude`, and `--include` are only valid for **cached** +> buildpacks (`--cached` flag). Using them on an uncached build is a hard error. + +### Packaging profiles + +A buildpack author defines named profiles in `manifest.yml` under the +`packaging_profiles` key: + +```yaml +packaging_profiles: + minimal: + description: "Core runtime only — no agents or profilers" + exclude: + - agent-dep + - profiler-dep + + no-profiler: + description: "Full build minus the profiler library" + exclude: + - profiler-dep +``` + +Each profile has: + +| Field | Type | Description | +|---------------|----------------|-------------| +| `description` | string | Human-readable summary shown by `buildpack-packager summary` | +| `exclude` | list of string | Dependency names to omit when this profile is active | + +Profile names must match `^[a-z0-9_-]+$` (lowercase letters, digits, hyphens, +underscores). This keeps names safe for embedding in zip filenames. + +All dependency names listed in a profile's `exclude` list must exist in the +manifest — a typo is a hard error at packaging time. + +### CLI flags + +| Flag | Argument | Description | +|------|----------|-------------| +| `--profile` | profile name | Activate a named profile from `manifest.yml` | +| `--exclude` | `dep1,dep2,...` | Additional dependencies to exclude (comma-separated) | +| `--include` | `dep1,dep2,...` | Restore dependencies that the active profile excluded | + +#### Examples + +```sh +# Build with the "minimal" profile (omits agent-dep and profiler-dep) +buildpack-packager --cached --stack cflinuxfs4 --profile minimal + +# Build with the "minimal" profile but restore profiler-dep +buildpack-packager --cached --stack cflinuxfs4 --profile minimal --include profiler-dep + +# No profile — just exclude a specific dependency +buildpack-packager --cached --stack cflinuxfs4 --exclude agent-dep + +# Combine a profile with an extra exclusion +buildpack-packager --cached --stack cflinuxfs4 --profile no-profiler --exclude agent-dep +``` + +### Resolution order + +1. Profile's `exclude` list is applied first. +2. `--exclude` names are **unioned** with the profile exclusions. +3. `--include` names are **removed** from the exclusion set (overrides the profile). + +Excluded dependencies are neither downloaded nor written into the packaged +`manifest.yml`. + +### Output filename + +The zip filename encodes which variant was built: + +| Options used | Filename pattern | +|---|---| +| No opts | `_buildpack-cached--v.zip` | +| `--profile minimal` | `_buildpack-cached-minimal--v.zip` | +| `--profile minimal --include profiler-dep` | `_buildpack-cached-minimal+custom--v.zip` | +| `--profile minimal --exclude extra-dep` | `_buildpack-cached-minimal+custom--v.zip` | +| `--exclude agent-dep` (no profile) | `_buildpack-cached-custom--v.zip` | + +The `+custom` suffix appears only when the result deviates from a pure profile: +either an extra `--exclude` was added, or `--include` actually overrode one of +the profile's exclusions. + +### Error conditions + +All validation errors are **hard errors** — the packager exits non-zero with a +descriptive message. There are no silent no-ops or warnings. + +| Situation | Error message | +|---|---| +| `--profile` / `--exclude` / `--include` on uncached build | `--profile/--exclude/--include are only valid for cached buildpacks` | +| `--include` without `--profile` | `--include requires --profile` | +| Unknown profile name | `packaging profile "" not found in manifest` | +| Invalid profile name characters | `profile name "" is invalid: must match ^[a-z0-9_-]+$` | +| Unknown dep in `--exclude` | `dependency "" not found in manifest` | +| Unknown dep in `--include` | `dependency "" not found in manifest` | +| `--include` of dep not excluded by profile | `--include "" has no effect: dependency is not excluded by the profile or --exclude` | +| Profile's `exclude` list references unknown dep | `profile "" references unknown dependency ""` | + +### Go API + +`Package()` is unchanged and delegates to `PackageWithOptions` with zero options: + +```go +// Legacy — unchanged behaviour +zipFile, err := packager.Package(bpDir, cacheDir, version, stack, cached) + +// New — selective packaging +zipFile, err := packager.PackageWithOptions(bpDir, cacheDir, version, stack, true, + packager.PackageOptions{ + Profile: "minimal", + Include: []string{"profiler-dep"}, + }) +``` + +`PackageOptions` fields: + +```go +type PackageOptions struct { + // Profile is a packaging_profiles key from manifest.yml. + Profile string + // Exclude lists additional dependency names to skip. + Exclude []string + // Include restores dependency names excluded by Profile. + // Requires Profile to be set. Hard error if a name was not excluded. + Include []string +} +``` + diff --git a/packager/buildpack-packager/main.go b/packager/buildpack-packager/main.go index 11f25deb..9ecf4ac7 100644 --- a/packager/buildpack-packager/main.go +++ b/packager/buildpack-packager/main.go @@ -40,23 +40,42 @@ type buildCmd struct { version string cacheDir string stack string + profile string + exclude string + include string } func (*buildCmd) Name() string { return "build" } func (*buildCmd) Synopsis() string { return "Create a buildpack zipfile from the current directory" } func (*buildCmd) Usage() string { - return `build -stack |-any-stack [-cached] [-version ] [-cachedir ]: + return `build -stack |-any-stack [-cached] [-version ] [-cachedir ] + [-profile ] [-exclude ] [-include ]: When run in a directory that is structured as a buildpack, creates a zip file. + -profile Name of a packaging profile defined in manifest.yml's + packaging_profiles section. Profiles declare which dependencies + to exclude from the cached zip. + + -exclude Comma-separated list of dependency names to exclude, in addition + to any exclusions implied by -profile. Names must exist in + manifest.yml. Example: -exclude datadog-javaagent,newrelic + + -include Comma-separated list of dependency names to force-include, + overriding exclusions implied by -profile. Useful for starting + from a restrictive profile and adding back a single dep. + Example: -profile minimal -include jprofiler-profiler + ` } func (b *buildCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&b.version, "version", "", "version to build as") f.BoolVar(&b.cached, "cached", false, "include dependencies") f.StringVar(&b.cacheDir, "cachedir", packager.CacheDir, "cache dir") - f.StringVar(&b.stack, "stack", "", "stack to package buildpack for") f.BoolVar(&b.anyStack, "any-stack", false, "package buildpack for any stack") + f.StringVar(&b.profile, "profile", "", "packaging profile defined in manifest.yml") + f.StringVar(&b.exclude, "exclude", "", "comma-separated dependency names to exclude") + f.StringVar(&b.include, "include", "", "comma-separated dependency names to include, overriding profile exclusions") } func (b *buildCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { if b.stack == "" && !b.anyStack { @@ -76,7 +95,24 @@ func (b *buildCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) b.version = strings.TrimSpace(string(v)) } - zipFile, err := packager.Package(".", b.cacheDir, b.version, b.stack, b.cached) + parseCSV := func(s string) []string { + var out []string + for _, name := range strings.Split(s, ",") { + name = strings.TrimSpace(name) + if name != "" { + out = append(out, name) + } + } + return out + } + + opts := packager.PackageOptions{ + Profile: b.profile, + Exclude: parseCSV(b.exclude), + Include: parseCSV(b.include), + } + + zipFile, err := packager.PackageWithOptions(".", b.cacheDir, b.version, b.stack, b.cached, opts) if err != nil { log.Printf("error while creating zipfile: %v", err) return subcommands.ExitFailure diff --git a/packager/fixtures/with_profiles/VERSION b/packager/fixtures/with_profiles/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/packager/fixtures/with_profiles/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/packager/fixtures/with_profiles/agent.txt b/packager/fixtures/with_profiles/agent.txt new file mode 100644 index 00000000..245bb405 --- /dev/null +++ b/packager/fixtures/with_profiles/agent.txt @@ -0,0 +1 @@ +agent-content \ No newline at end of file diff --git a/packager/fixtures/with_profiles/bin/filename b/packager/fixtures/with_profiles/bin/filename new file mode 100644 index 00000000..80d7e98b --- /dev/null +++ b/packager/fixtures/with_profiles/bin/filename @@ -0,0 +1 @@ +awesome content \ No newline at end of file diff --git a/packager/fixtures/with_profiles/core.txt b/packager/fixtures/with_profiles/core.txt new file mode 100644 index 00000000..55a0619e --- /dev/null +++ b/packager/fixtures/with_profiles/core.txt @@ -0,0 +1 @@ +core-content \ No newline at end of file diff --git a/packager/fixtures/with_profiles/manifest.yml b/packager/fixtures/with_profiles/manifest.yml new file mode 100644 index 00000000..dd33a5cf --- /dev/null +++ b/packager/fixtures/with_profiles/manifest.yml @@ -0,0 +1,38 @@ +--- +language: ruby +default_versions: +- name: core-dep + version: 1.0.0 +dependencies: +- name: core-dep + version: 1.0.0 + sha256: 30d444543fbf15edc568d29705afe7ecbc813c4152b09f403f48393b0023c8a7 + uri: file://PLACEHOLDER_CORE + cf_stacks: + - cflinuxfs4 +- name: agent-dep + version: 2.0.0 + sha256: 4376301a8bdefc71d4c686a919f9cae5232707c00a5949994b1faba7aefc3796 + uri: file://PLACEHOLDER_AGENT + cf_stacks: + - cflinuxfs4 +- name: profiler-dep + version: 3.0.0 + sha256: b6d1482cd7b189071c3c48e936f04f7d0a6f7a0a75df6ecb6cb4b55fd398baa2 + uri: file://PLACEHOLDER_PROFILER + cf_stacks: + - cflinuxfs4 +include_files: +- manifest.yml +- VERSION +- bin/filename +packaging_profiles: + minimal: + description: Core deps only. No agents or profilers. + exclude: + - agent-dep + - profiler-dep + no-profiler: + description: Core and agents only. No profilers. + exclude: + - profiler-dep diff --git a/packager/fixtures/with_profiles/profiler.txt b/packager/fixtures/with_profiles/profiler.txt new file mode 100644 index 00000000..d9b8cba9 --- /dev/null +++ b/packager/fixtures/with_profiles/profiler.txt @@ -0,0 +1 @@ +profiler-content \ No newline at end of file diff --git a/packager/models.go b/packager/models.go index e9150d91..d4b309f0 100644 --- a/packager/models.go +++ b/packager/models.go @@ -15,6 +15,12 @@ type Dependency struct { type SubDependency struct{ Name string } type Dependencies []Dependency +// PackagingProfile defines a named dependency exclusion set for use at packaging time. +type PackagingProfile struct { + Description string `yaml:"description"` + Exclude []string `yaml:"exclude"` +} + type Manifest struct { Language string `yaml:"language"` Stack string `yaml:"stack"` @@ -25,6 +31,7 @@ type Manifest struct { Name string `yaml:"name"` Version string `yaml:"version"` } `yaml:"default_versions"` + PackagingProfiles map[string]PackagingProfile `yaml:"packaging_profiles"` } type File struct { diff --git a/packager/models_test.go b/packager/models_test.go index 1e8ac076..281ac848 100644 --- a/packager/models_test.go +++ b/packager/models_test.go @@ -7,6 +7,7 @@ import ( httpmock "github.com/jarcoal/httpmock" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + yaml "gopkg.in/yaml.v2" ) var _ = Describe("Packager", func() { @@ -33,4 +34,43 @@ var _ = Describe("Packager", func() { })) }) }) + + Describe("PackagingProfile YAML unmarshalling", func() { + It("parses packaging_profiles from manifest YAML", func() { + raw := ` +language: ruby +dependencies: [] +packaging_profiles: + minimal: + description: "JDKs only" + exclude: + - agent-dep + - profiler-dep + standard: + description: "Core and OSS agents" + exclude: + - profiler-dep +` + var m packager.Manifest + Expect(yaml.Unmarshal([]byte(raw), &m)).To(Succeed()) + Expect(m.PackagingProfiles).To(HaveLen(2)) + + minimal := m.PackagingProfiles["minimal"] + Expect(minimal.Description).To(Equal("JDKs only")) + Expect(minimal.Exclude).To(ConsistOf("agent-dep", "profiler-dep")) + + standard := m.PackagingProfiles["standard"] + Expect(standard.Description).To(Equal("Core and OSS agents")) + Expect(standard.Exclude).To(ConsistOf("profiler-dep")) + }) + + It("has nil PackagingProfiles when the section is absent", func() { + raw := `language: ruby +dependencies: [] +` + var m packager.Manifest + Expect(yaml.Unmarshal([]byte(raw), &m)).To(Succeed()) + Expect(m.PackagingProfiles).To(BeNil()) + }) + }) }) diff --git a/packager/packager.go b/packager/packager.go index 2f081068..b0c61b04 100644 --- a/packager/packager.go +++ b/packager/packager.go @@ -15,12 +15,17 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "github.com/cloudfoundry/libbuildpack" "gopkg.in/yaml.v2" ) +// profileNameRe restricts profile names to safe characters that can be +// embedded in a zip filename without escaping or path-traversal risk. +var profileNameRe = regexp.MustCompile(`^[a-z0-9_-]+$`) + type sha struct { Sha map[string]string `yaml:"sha"` } @@ -152,7 +157,92 @@ func downloadDependency(dependency Dependency, cacheDir string) (File, error) { return File{file, filepath.Join(cacheDir, file)}, nil } +// PackageOptions configures optional selective-packaging behaviour for PackageWithOptions. +// All fields are optional; zero values produce identical output to the legacy Package function. +type PackageOptions struct { + // Profile is the name of a packaging_profiles entry in manifest.yml. + // Its exclude list is applied before Exclude and Include are processed. + Profile string + // Exclude is an additional list of dependency names to skip, unioned with + // any exclusions implied by Profile. + Exclude []string + // Include restores specific dependency names that would otherwise be excluded + // by Profile. It is a no-op (with a warning) when Profile is empty. + Include []string +} + +// resolveExclusions returns the set of dependency names that should be skipped +// during packaging, and the count of include names that actually overrode a +// profile exclusion (used by the caller to decide the +custom filename suffix). +// Resolution order: +// 1. Profile's exclude list (if a profile is named). +// 2. Explicit Exclude names are unioned in. +// 3. Explicit Include names are removed (overrides profile exclusions). +// +// An error is returned if the profile name is unknown or if any exclude/include +// name does not exist in the manifest (including names in the profile's exclude +// list itself). +func resolveExclusions(manifest Manifest, profile string, exclude []string, include []string) (map[string]struct{}, int, error) { + // Build a set of all known dependency names for validation. + depNames := make(map[string]struct{}, len(manifest.Dependencies)) + for _, d := range manifest.Dependencies { + depNames[d.Name] = struct{}{} + } + + // 1. Start with profile exclusions. + result := make(map[string]struct{}) + if profile != "" { + if !profileNameRe.MatchString(profile) { + return nil, 0, fmt.Errorf("profile name %q is invalid: must match ^[a-z0-9_-]+$", profile) + } + p, ok := manifest.PackagingProfiles[profile] + if !ok { + return nil, 0, fmt.Errorf("packaging profile %q not found in manifest", profile) + } + for _, name := range p.Exclude { + if _, ok := depNames[name]; !ok { + return nil, 0, fmt.Errorf("profile %q references unknown dependency %q", profile, name) + } + result[name] = struct{}{} + } + } + + // 2. Union with explicitly excluded names. + for _, name := range exclude { + if _, ok := depNames[name]; !ok { + return nil, 0, fmt.Errorf("dependency %q not found in manifest", name) + } + result[name] = struct{}{} + } + + // 3. Remove explicitly included names (overrides profile). + // Count how many of these actually removed something from the set. + // It is a hard error to --include a name that was never excluded (likely a typo). + effectiveIncludes := 0 + for _, name := range include { + if _, ok := depNames[name]; !ok { + return nil, 0, fmt.Errorf("dependency %q not found in manifest", name) + } + if _, wasExcluded := result[name]; !wasExcluded { + return nil, 0, fmt.Errorf("--include %q has no effect: dependency is not excluded by the profile or --exclude", name) + } + effectiveIncludes++ + delete(result, name) + } + + return result, effectiveIncludes, nil +} + +// Package is the legacy entry point. It delegates to PackageWithOptions with +// zero-value options so all existing callers remain unaffected. func Package(bpDir, cacheDir, version, stack string, cached bool) (string, error) { + return PackageWithOptions(bpDir, cacheDir, version, stack, cached, PackageOptions{}) +} + +// PackageWithOptions creates a buildpack zip, optionally filtering dependencies +// according to opts (profile, exclude, include). When opts is zero-value the +// behaviour is identical to the legacy Package function. +func PackageWithOptions(bpDir, cacheDir, version, stack string, cached bool, opts PackageOptions) (string, error) { bpDir, err := filepath.Abs(bpDir) if err != nil { return "", err @@ -177,6 +267,28 @@ func Package(bpDir, cacheDir, version, stack string, cached bool) (string, error return "", err } + // --profile/--exclude/--include only apply to cached buildpacks. + if !cached && (opts.Profile != "" || len(opts.Exclude) > 0 || len(opts.Include) > 0) { + return "", fmt.Errorf("--profile/--exclude/--include are only valid for cached buildpacks") + } + + // --include requires --profile (nothing to override otherwise). + if opts.Profile == "" && len(opts.Include) > 0 { + return "", fmt.Errorf("--include requires --profile") + } + + // Resolve which dependency names to skip before the download loop. + // On uncached builds the exclusion set is never used, so skip validation. + excluded := map[string]struct{}{} + effectiveIncludes := 0 + if cached { + var err2 error + excluded, effectiveIncludes, err2 = resolveExclusions(manifest, opts.Profile, opts.Exclude, opts.Include) + if err2 != nil { + return "", err2 + } + } + if manifest.PrePackage != "" { cmd := exec.Command(manifest.PrePackage) cmd.Dir = dir @@ -207,6 +319,12 @@ func Package(bpDir, cacheDir, version, stack string, cached bool) (string, error } dependenciesForStack := []interface{}{} for idx, d := range manifest.Dependencies { + // Skip excluded dependencies — they are not downloaded and are not + // written into the packaged manifest.yml. + if _, skip := excluded[d.Name]; skip { + continue + } + for _, s := range d.Stacks { if stack == "" || s == stack { dependencyMap := deps[idx] @@ -242,7 +360,21 @@ func Package(bpDir, cacheDir, version, stack string, cached bool) (string, error cachedPart = "-cached" } - fileName := fmt.Sprintf("%s_buildpack%s%s-v%s.zip", manifest.Language, cachedPart, stackPart, version) + // Build the profile/exclusion suffix for the filename. + // +custom is only appended when there is genuine customisation on top of + // the profile: either an extra --exclude, or an --include that actually + // overrode one of the profile's exclusions (effectiveIncludes > 0). + profilePart := "" + if opts.Profile != "" { + profilePart = "-" + opts.Profile + if len(opts.Exclude) > 0 || effectiveIncludes > 0 { + profilePart += "+custom" + } + } else if len(opts.Exclude) > 0 { + profilePart = "-custom" + } + + fileName := fmt.Sprintf("%s_buildpack%s%s%s-v%s.zip", manifest.Language, cachedPart, profilePart, stackPart, version) zipFile := filepath.Join(bpDir, fileName) if err := ZipFiles(zipFile, files); err != nil { diff --git a/packager/packager_test.go b/packager/packager_test.go index e054a010..338cfac8 100644 --- a/packager/packager_test.go +++ b/packager/packager_test.go @@ -19,6 +19,405 @@ import ( . "github.com/onsi/gomega" ) +// depNamesInManifest returns the dependency names listed in the manifest.yml +// that is embedded in the given zip file. +func depNamesInManifest(zipFile string) ([]string, error) { + manifestYml, err := ZipContents(zipFile, "manifest.yml") + if err != nil { + return nil, err + } + var m packager.Manifest + if err := yaml.Unmarshal([]byte(manifestYml), &m); err != nil { + return nil, err + } + names := make([]string, 0, len(m.Dependencies)) + for _, d := range m.Dependencies { + names = append(names, d.Name) + } + return names, nil +} + +var _ = Describe("PackageWithOptions", func() { + var ( + buildpackDir string + version string + cacheDir string + stack string + zipFile string + err error + ) + + BeforeEach(func() { + stack = "cflinuxfs4" + buildpackDir = "./fixtures/with_profiles" + cacheDir, err = os.MkdirTemp("", "packager-cachedir") + Expect(err).To(BeNil()) + version = fmt.Sprintf("1.0.0.%s", time.Now().Format("20060102150405")) + httpmock.Reset() + }) + + AfterEach(func() { + os.Remove(zipFile) + os.RemoveAll(cacheDir) + }) + + // patchedFixtureDir returns (tmpDir, fixtureAbs) — a temp copy of + // with_profiles where PLACEHOLDER_* URIs are replaced with real file:// + // paths so the packager can copy them during cached builds. + patchedFixtureDir := func() (string, string) { + GinkgoHelper() + fixtureAbs, err := filepath.Abs("./fixtures/with_profiles") + Expect(err).NotTo(HaveOccurred()) + + tmpDir, err := os.MkdirTemp("", "bp_fixture_cached") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(os.RemoveAll, tmpDir) + + Expect(libbuildpack.CopyDirectory(fixtureAbs, tmpDir)).To(Succeed()) + + manifestPath := filepath.Join(tmpDir, "manifest.yml") + raw, err := os.ReadFile(manifestPath) + Expect(err).NotTo(HaveOccurred()) + + patched := string(raw) + patched = strings.ReplaceAll(patched, "file://PLACEHOLDER_CORE", "file://"+filepath.Join(fixtureAbs, "core.txt")) + patched = strings.ReplaceAll(patched, "file://PLACEHOLDER_AGENT", "file://"+filepath.Join(fixtureAbs, "agent.txt")) + patched = strings.ReplaceAll(patched, "file://PLACEHOLDER_PROFILER", "file://"+filepath.Join(fixtureAbs, "profiler.txt")) + + Expect(os.WriteFile(manifestPath, []byte(patched), 0644)).To(Succeed()) + return tmpDir, fixtureAbs + } + + Context("no profile, no exclude, no include (zero opts)", func() { + JustBeforeEach(func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, false, packager.PackageOptions{}) + Expect(err).To(BeNil()) + }) + + It("bundles all stack-matching dependencies", func() { + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "agent-dep", "profiler-dep")) + }) + + It("produces the standard filename with no profile suffix", func() { + dir, _ := filepath.Abs(buildpackDir) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cflinuxfs4-v%s.zip", version)))) + }) + }) + + Context("explicit --exclude of one dependency", func() { + It("omits the excluded dependency from the manifest", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "profiler-dep")) + Expect(names).NotTo(ContainElement("agent-dep")) + }) + }) + + Context("named profile that excludes two dependencies", func() { + It("omits all profile-excluded dependencies", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "minimal", + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep")) + Expect(names).NotTo(ContainElement("agent-dep")) + Expect(names).NotTo(ContainElement("profiler-dep")) + }) + }) + + Context("profile combined with extra --exclude", func() { + It("applies union of profile and explicit excludes", func() { + dir, _ := patchedFixtureDir() + // no-profiler only excludes profiler-dep; adding --exclude agent-dep unions both + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "no-profiler", + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep")) + }) + }) + + Context("profile with --include restoring an excluded dependency", func() { + It("restores the included dependency while keeping other exclusions", func() { + dir, _ := patchedFixtureDir() + // minimal excludes both agent-dep and profiler-dep; --include restores profiler-dep + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "minimal", + Include: []string{"profiler-dep"}, + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "profiler-dep")) + Expect(names).NotTo(ContainElement("agent-dep")) + }) + }) + + Context("profile with --exclude and --include combined", func() { + It("applies exclude union then include override", func() { + dir, _ := patchedFixtureDir() + // no-profiler excludes profiler-dep; we also exclude agent-dep but restore profiler-dep + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "no-profiler", + Exclude: []string{"agent-dep"}, + Include: []string{"profiler-dep"}, + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "profiler-dep")) + }) + }) + + Context("--include without --profile", func() { + It("returns an error", func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, true, packager.PackageOptions{ + Include: []string{"core-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring("--include requires --profile"))) + }) + }) + + Context("uncached buildpack rejects profile/exclude/include flags", func() { + It("returns an error when --profile is used on an uncached build", func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, false, packager.PackageOptions{ + Profile: "minimal", + }) + Expect(err).To(MatchError(ContainSubstring("only valid for cached buildpacks"))) + }) + + It("returns an error when --exclude is used on an uncached build", func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, false, packager.PackageOptions{ + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring("only valid for cached buildpacks"))) + }) + + It("returns an error when --include is used on an uncached build", func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, false, packager.PackageOptions{ + Profile: "minimal", + Include: []string{"profiler-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring("only valid for cached buildpacks"))) + }) + }) + + Context("manifest validation errors (cached=true)", func() { + It("unknown profile name returns an error", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "does-not-exist", + }) + Expect(err).To(MatchError(ContainSubstring(`packaging profile "does-not-exist" not found in manifest`))) + }) + + It("--exclude with unknown dependency name returns an error", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"no-such-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring(`dependency "no-such-dep" not found in manifest`))) + }) + + It("--include with unknown dependency name returns an error", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "minimal", + Include: []string{"no-such-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring(`dependency "no-such-dep" not found in manifest`))) + }) + + It("profile with a bad exclude name returns an error referencing the profile", func() { + dir, _ := patchedFixtureDir() + // Inject a profile whose exclude list names a non-existent dep. + badManifest := filepath.Join(dir, "manifest.yml") + raw, readErr := os.ReadFile(badManifest) + Expect(readErr).To(BeNil()) + patched := strings.Replace(string(raw), + "packaging_profiles:", + "packaging_profiles:\n bad-exclude:\n description: profile with bogus dep\n exclude:\n - no-such-dep", + 1) + Expect(os.WriteFile(badManifest, []byte(patched), 0644)).To(Succeed()) + + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "bad-exclude", + }) + Expect(err).To(MatchError(ContainSubstring(`profile "bad-exclude" references unknown dependency "no-such-dep"`))) + }) + + It("invalid profile name (contains spaces) returns an error", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "my profile", + }) + Expect(err).To(MatchError(ContainSubstring(`profile name "my profile" is invalid`))) + }) + + It("invalid profile name (contains slash) returns an error", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "../../etc/passwd", + }) + Expect(err).To(MatchError(ContainSubstring(`profile name "../../etc/passwd" is invalid`))) + }) + + It("--include of dep not excluded by profile or --exclude returns an error", func() { + dir, _ := patchedFixtureDir() + // no-profiler excludes profiler-dep; core-dep is never excluded → hard error + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "no-profiler", + Include: []string{"core-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring(`--include "core-dep" has no effect`))) + }) + }) + + Context("backward compat: Package() delegates to PackageWithOptions with zero opts", func() { + JustBeforeEach(func() { + zipFile, err = packager.Package(buildpackDir, cacheDir, version, stack, false) + Expect(err).To(BeNil()) + }) + + It("bundles all stack-matching dependencies", func() { + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "agent-dep", "profiler-dep")) + }) + + It("produces the standard filename with no profile suffix", func() { + dir, _ := filepath.Abs(buildpackDir) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cflinuxfs4-v%s.zip", version)))) + }) + }) + + Context("zip filename variants", func() { + // Opts-bearing tests need cached=true + real file:// URIs. + // The zero-opts test stays uncached (no downloads needed). + + It("no opts → no profile suffix", func() { + zipFile, err = packager.PackageWithOptions(buildpackDir, cacheDir, version, stack, false, packager.PackageOptions{}) + Expect(err).To(BeNil()) + dir, _ := filepath.Abs(buildpackDir) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cflinuxfs4-v%s.zip", version)))) + }) + + It("profile only → -", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{Profile: "minimal"}) + Expect(err).To(BeNil()) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cached-minimal-cflinuxfs4-v%s.zip", version)))) + }) + + It("profile + exclude → -+custom", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "no-profiler", Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cached-no-profiler+custom-cflinuxfs4-v%s.zip", version)))) + }) + + It("profile + include that overrides a profile exclusion → -+custom", func() { + dir, _ := patchedFixtureDir() + // minimal excludes agent-dep and profiler-dep; restoring profiler-dep is an effective override + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "minimal", Include: []string{"profiler-dep"}, + }) + Expect(err).To(BeNil()) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cached-minimal+custom-cflinuxfs4-v%s.zip", version)))) + }) + + It("profile + include of dep NOT in profile exclusions → hard error", func() { + dir, _ := patchedFixtureDir() + // no-profiler only excludes profiler-dep; --include core-dep was never excluded → hard error + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "no-profiler", Include: []string{"core-dep"}, + }) + Expect(err).To(MatchError(ContainSubstring(`--include "core-dep" has no effect`))) + }) + + It("exclude only (no profile) → -custom", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + Expect(zipFile).To(Equal(filepath.Join(dir, fmt.Sprintf("ruby_buildpack-cached-custom-cflinuxfs4-v%s.zip", version)))) + }) + }) + + Context("--exclude multiple dependencies at once", func() { + It("omits all listed deps from the packaged manifest", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"agent-dep", "profiler-dep"}, + }) + Expect(err).To(BeNil()) + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep")) + }) + }) + + Context("cached=true with exclusion (excluded dep not downloaded, not in zip)", func() { + It("excluded dependency is not present as a binary file in the zip", func() { + dir, fixtureAbs := patchedFixtureDir() + agentURI := "file://" + filepath.Join(fixtureAbs, "agent.txt") + + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + + // The agent binary path inside the zip is keyed by the MD5 of its URI. + agentZipPath := fmt.Sprintf("dependencies/%x/agent.txt", md5.Sum([]byte(agentURI))) + _, lookupErr := ZipContents(zipFile, agentZipPath) + Expect(lookupErr).To(MatchError(ContainSubstring("not found in"))) + }) + + It("non-excluded dependencies are still downloaded and present in the zip", func() { + dir, _ := patchedFixtureDir() + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Exclude: []string{"agent-dep"}, + }) + Expect(err).To(BeNil()) + + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "profiler-dep")) + }) + + It("profile + include: restored dep is downloaded and present in the manifest", func() { + dir, _ := patchedFixtureDir() + // minimal excludes both agent-dep and profiler-dep; --include restores profiler-dep + zipFile, err = packager.PackageWithOptions(dir, cacheDir, version, stack, true, packager.PackageOptions{ + Profile: "minimal", + Include: []string{"profiler-dep"}, + }) + Expect(err).To(BeNil()) + + names, err := depNamesInManifest(zipFile) + Expect(err).To(BeNil()) + Expect(names).To(ConsistOf("core-dep", "profiler-dep")) + Expect(names).NotTo(ContainElement("agent-dep")) + }) + }) +}) + var _ = Describe("Packager", func() { var ( buildpackDir string diff --git a/packager/summary.go b/packager/summary.go index 7381c30c..67237f8d 100644 --- a/packager/summary.go +++ b/packager/summary.go @@ -62,5 +62,17 @@ func Summary(bpDir string) (string, error) { } } + if len(manifest.PackagingProfiles) > 0 { + out += "\nPackaging profiles:\n\n" + profileNames := make([]string, 0, len(manifest.PackagingProfiles)) + for name := range manifest.PackagingProfiles { + profileNames = append(profileNames, name) + } + sort.Strings(profileNames) + for _, name := range profileNames { + out += fmt.Sprintf(" %-12s %s\n", name, manifest.PackagingProfiles[name].Description) + } + } + return out, nil } diff --git a/packager/summary_test.go b/packager/summary_test.go index d36f3855..df867f75 100644 --- a/packager/summary_test.go +++ b/packager/summary_test.go @@ -1,6 +1,8 @@ package packager_test import ( + "strings" + "github.com/cloudfoundry/libbuildpack/packager" httpmock "github.com/jarcoal/httpmock" . "github.com/onsi/ginkgo/v2" @@ -61,5 +63,22 @@ Packaged binaries: Expect(packager.Summary(buildpackDir)).To(Equal("")) }) }) + + Context("manifest has packaging_profiles", func() { + BeforeEach(func() { + buildpackDir = "./fixtures/with_profiles" + }) + It("prints a Packaging profiles section with sorted profile names and descriptions", func() { + s, err := packager.Summary(buildpackDir) + Expect(err).NotTo(HaveOccurred()) + Expect(s).To(ContainSubstring("Packaging profiles:")) + Expect(s).To(ContainSubstring("minimal")) + Expect(s).To(ContainSubstring("Core deps only. No agents or profilers.")) + Expect(s).To(ContainSubstring("no-profiler")) + Expect(s).To(ContainSubstring("Core and agents only. No profilers.")) + // minimal sorts before no-profiler + Expect(strings.Index(s, "minimal")).To(BeNumerically("<", strings.Index(s, "no-profiler"))) + }) + }) }) })